fix permissions and teacher attendance workflows
This commit is contained in:
@@ -63,6 +63,7 @@ import { SyncModule } from './sync/sync.module';
|
||||
import { NotificationsModule } from './notifications/notifications.module';
|
||||
import { ArchiveModule } from './archive/archive.module';
|
||||
import { ExpenseTypesModule } from './expense-types/expense-types.module';
|
||||
import { DatabaseMigrationsModule } from './database/database-migrations.module';
|
||||
|
||||
import { IntegrationConfig, IntegrationConfigDetail } from './integration/entities/integration-config.entity';
|
||||
import { IntegrationConfigModule } from './integration/config/config.module';
|
||||
@@ -142,6 +143,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
|
||||
};
|
||||
},
|
||||
}),
|
||||
DatabaseMigrationsModule,
|
||||
AuthModule,
|
||||
RbacModule,
|
||||
StudentsModule,
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Controller, Post, Body, Get, Request, Req, UseGuards } from '@nestjs/common';
|
||||
import { Controller, Post, Body, Get, Request, Req } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto } from './dto/auth.dto';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import { extractRequestInfo } from '../common/request-utils';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { Public } from './decorators/public.decorator';
|
||||
import { Authenticated } from './decorators/authenticated.decorator';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
@@ -45,8 +45,7 @@ export class AuthController {
|
||||
}
|
||||
}
|
||||
|
||||
@Public()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Authenticated()
|
||||
@Get('profile')
|
||||
getProfile(@Request() req: any) {
|
||||
return req.user;
|
||||
|
||||
29
apps/server/src/auth/auth.service.spec.ts
Normal file
29
apps/server/src/auth/auth.service.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
describe('AuthService — super admin identity', () => {
|
||||
it('marks the preset 超管 role as super admin in the JWT payload', async () => {
|
||||
const userRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
name: '管理员',
|
||||
passwordHash: await bcrypt.hash('secret', 4),
|
||||
isActive: true,
|
||||
roles: [{ name: '超管', status: 1 }],
|
||||
}),
|
||||
save: jest.fn(),
|
||||
};
|
||||
const jwtService = { sign: jest.fn().mockReturnValue('token') };
|
||||
const rbacService = {
|
||||
getUserPermissions: jest.fn().mockResolvedValue(['attendance:create']),
|
||||
};
|
||||
const service = new AuthService(userRepo as never, jwtService as never, rbacService as never);
|
||||
|
||||
await service.login({ username: 'admin', password: 'secret' }, '127.0.0.1');
|
||||
|
||||
expect(jwtService.sign).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isSuperAdmin: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -59,7 +59,12 @@ export class AuthService {
|
||||
|
||||
// 获取用户权限
|
||||
const permissions = await this.rbacService.getUserPermissions(user.id);
|
||||
const isSuperAdmin = user.roles?.some((r) => r.name === 'super_admin') ?? false;
|
||||
const isSuperAdmin =
|
||||
user.roles?.some(
|
||||
(role) =>
|
||||
role.status === 1 &&
|
||||
(role.name === '超管' || role.name === 'super_admin' || role.code === 'super_admin'),
|
||||
) ?? false;
|
||||
const payload = { sub: user.id, username: user.username, permissions, isSuperAdmin };
|
||||
|
||||
// 获取角色名称列表
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const AUTHENTICATED_KEY = 'authenticatedOnly';
|
||||
|
||||
/**
|
||||
* 标记为“只需要登录”的接口:仍由全局 JwtAuthGuard 校验 JWT,
|
||||
* 但 PermissionGuard 不要求具体业务权限。
|
||||
*/
|
||||
export const Authenticated = () => SetMetadata(AUTHENTICATED_KEY, true);
|
||||
50
apps/server/src/auth/guards/permission.guard.spec.ts
Normal file
50
apps/server/src/auth/guards/permission.guard.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { PermissionGuard } from './permission.guard';
|
||||
|
||||
describe('PermissionGuard', () => {
|
||||
const createContext = (user: unknown) =>
|
||||
({
|
||||
getHandler: () => function handler() {},
|
||||
getClass: () => class Controller {},
|
||||
switchToHttp: () => ({ getRequest: () => ({ user }) }),
|
||||
}) as never;
|
||||
|
||||
it('denies routes that forgot to declare permissions', () => {
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn().mockReturnValue(false),
|
||||
getAllAndMerge: jest.fn().mockReturnValue(undefined),
|
||||
};
|
||||
const guard = new PermissionGuard(reflector as never);
|
||||
|
||||
expect(guard.canActivate(createContext({ permissions: ['dashboard:view'] }))).toBe(false);
|
||||
});
|
||||
|
||||
it('allows explicitly public routes without a user', () => {
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn().mockReturnValue(true),
|
||||
getAllAndMerge: jest.fn(),
|
||||
};
|
||||
const guard = new PermissionGuard(reflector as never);
|
||||
|
||||
expect(guard.canActivate(createContext(undefined))).toBe(true);
|
||||
});
|
||||
|
||||
it('allows authenticated-only routes for logged-in users without requiring profile:view', () => {
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn().mockReturnValueOnce(false).mockReturnValueOnce(true),
|
||||
getAllAndMerge: jest.fn(),
|
||||
};
|
||||
const guard = new PermissionGuard(reflector as never);
|
||||
|
||||
expect(guard.canActivate(createContext({ permissions: [] }))).toBe(true);
|
||||
});
|
||||
|
||||
it('denies authenticated-only routes when no authenticated user is present', () => {
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn().mockReturnValueOnce(false).mockReturnValueOnce(true),
|
||||
getAllAndMerge: jest.fn(),
|
||||
};
|
||||
const guard = new PermissionGuard(reflector as never);
|
||||
|
||||
expect(guard.canActivate(createContext(undefined))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -2,18 +2,19 @@ import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||
import { PERMISSION_KEY } from '../decorators/permission.decorator';
|
||||
import { AUTHENTICATED_KEY } from '../decorators/authenticated.decorator';
|
||||
|
||||
/**
|
||||
* 权限守卫 — 默认放行策略(⚠️ 安全关键)
|
||||
* 权限守卫 — 默认拒绝策略(安全关键)
|
||||
*
|
||||
* 当 handler/controller 上不存在 @RequirePermission 时,守卫放行(仅需登录即可访问)。
|
||||
* 这是有意的设计选择:所有敏感路由必须显式标注 @RequirePermission。
|
||||
* 当 handler/controller 上不存在 @RequirePermission、@Authenticated 且未标记 @Public 时,守卫拒绝访问。
|
||||
* 所有路由必须显式声明公开、仅登录或所需权限。
|
||||
*
|
||||
* ⚠️ 新增路由时务必添加 @RequirePermission,否则该路由对所有已认证用户开放!
|
||||
* ⚠️ 新增路由时务必添加 @RequirePermission、@Authenticated 或 @Public。
|
||||
* 建议配合 lint 规则确保无遗漏。
|
||||
*/
|
||||
@Injectable()
|
||||
export class PermissionGuard implements CanActivate {
|
||||
@Injectable()
|
||||
export class PermissionGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
@@ -24,20 +25,28 @@ import { PERMISSION_KEY } from '../decorators/permission.decorator';
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
|
||||
// 2. 获取所需权限(getAllAndMerge 合并 handler+class 层的所有 metadata)
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
// 2. @Authenticated() 只要求已登录,具体 JWT 有效性由 JwtAuthGuard 负责。
|
||||
const authenticatedOnly = this.reflector.getAllAndOverride<boolean>(AUTHENTICATED_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (authenticatedOnly) return !!user;
|
||||
|
||||
// 3. 获取所需权限(getAllAndMerge 合并 handler+class 层的所有 metadata)
|
||||
const requiredPermissions = this.reflector.getAllAndMerge<string[]>(PERMISSION_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
// 无装饰器 = 仅需登录即可,放行
|
||||
if (!requiredPermissions || requiredPermissions.length === 0) return true;
|
||||
// 无权限声明且非 @Public/@Authenticated:默认拒绝,避免新增接口意外裸奔
|
||||
if (!requiredPermissions || requiredPermissions.length === 0) return false;
|
||||
|
||||
// 3. 从 JWT payload 获取用户权限
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
// 4. 从 JWT payload 获取用户权限
|
||||
if (!user || !user.permissions || !Array.isArray(user.permissions)) return false;
|
||||
|
||||
// 4. OR 匹配:用户拥有 requiredPermissions 中任一权限即可通过
|
||||
// 5. OR 匹配:用户拥有 requiredPermissions 中任一权限即可通过
|
||||
return requiredPermissions.some((p) => user.permissions.includes(p));
|
||||
}
|
||||
}
|
||||
|
||||
48
apps/server/src/auth/strategies/jwt.strategy.spec.ts
Normal file
48
apps/server/src/auth/strategies/jwt.strategy.spec.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
|
||||
describe('JwtStrategy', () => {
|
||||
const config = { get: jest.fn().mockReturnValue('secret') };
|
||||
|
||||
it('refreshes permissions from the database instead of trusting stale JWT permissions', async () => {
|
||||
const userRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: 7,
|
||||
username: 'teacher',
|
||||
isActive: true,
|
||||
isArchived: false,
|
||||
roles: [
|
||||
{
|
||||
name: '老师',
|
||||
status: 1,
|
||||
permissions: [{ code: 'class:view' }, { code: 'attendance:view' }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
const strategy = new JwtStrategy(config as never, userRepo as never);
|
||||
|
||||
await expect(
|
||||
strategy.validate({ sub: 7, username: 'teacher', permissions: ['user:delete'] }),
|
||||
).resolves.toEqual({
|
||||
id: 7,
|
||||
username: 'teacher',
|
||||
permissions: ['class:view', 'attendance:view'],
|
||||
isSuperAdmin: false,
|
||||
roles: ['老师'],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ id: 7, isActive: false, isArchived: false, roles: [] }],
|
||||
[{ id: 7, isActive: true, isArchived: true, roles: [] }],
|
||||
[null],
|
||||
])('rejects disabled, archived, or deleted users', async (user) => {
|
||||
const userRepo = { findOne: jest.fn().mockResolvedValue(user) };
|
||||
const strategy = new JwtStrategy(config as never, userRepo as never);
|
||||
|
||||
await expect(strategy.validate({ sub: 7, username: 'teacher' })).rejects.toBeInstanceOf(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,18 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Request } from 'express';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { User } from '../../entities/user.entity';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(config: ConfigService) {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
@InjectRepository(User) private readonly userRepo: Repository<User>,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromExtractors([
|
||||
// 1. Standard Bearer header (existing behavior)
|
||||
@@ -25,12 +31,32 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: any) {
|
||||
async validate(payload: { sub?: number; username?: string }) {
|
||||
if (!payload.sub) throw new UnauthorizedException('登录状态无效');
|
||||
const user = await this.userRepo.findOne({
|
||||
where: { id: payload.sub },
|
||||
relations: ['roles', 'roles.permissions'],
|
||||
});
|
||||
if (!user || !user.isActive || user.isArchived) {
|
||||
throw new UnauthorizedException('账号已失效,请重新登录');
|
||||
}
|
||||
|
||||
const permissions = new Set<string>();
|
||||
const roles: string[] = [];
|
||||
let isSuperAdmin = false;
|
||||
for (const role of user.roles ?? []) {
|
||||
if (role.status !== 1) continue;
|
||||
roles.push(role.name);
|
||||
if (role.name === '超管' || role.name === 'super_admin') isSuperAdmin = true;
|
||||
for (const permission of role.permissions ?? []) permissions.add(permission.code);
|
||||
}
|
||||
|
||||
return {
|
||||
id: payload.sub,
|
||||
username: payload.username,
|
||||
permissions: payload.permissions || [],
|
||||
isSuperAdmin: payload.isSuperAdmin || false,
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
permissions: [...permissions],
|
||||
isSuperAdmin,
|
||||
roles,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,16 @@ import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationType } from '../entities/notification.entity';
|
||||
import * as ExcelJS from 'exceljs';
|
||||
|
||||
interface RequestUser {
|
||||
id: number;
|
||||
permissions?: string[];
|
||||
isSuperAdmin?: boolean;
|
||||
}
|
||||
|
||||
interface AuthenticatedRequest {
|
||||
user: RequestUser;
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('classes')
|
||||
export class ClassesController {
|
||||
@@ -40,30 +50,48 @@ export class ClassesController {
|
||||
private readonly notificationsService: NotificationsService,
|
||||
) {}
|
||||
|
||||
private assertReadAccess(req: AuthenticatedRequest, classId: number) {
|
||||
const canManageAll =
|
||||
req.user.isSuperAdmin === true || req.user.permissions?.includes('class:edit') === true;
|
||||
return this.service.assertClassAccess(req.user.id, classId, canManageAll);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('class:view')
|
||||
findAll(@Query() query: QueryClassDto) {
|
||||
return this.service.findAll(query);
|
||||
async findAll(@Query() query: QueryClassDto, @Request() req: AuthenticatedRequest) {
|
||||
const classIds = await this.service.getAccessibleClassIds(
|
||||
req.user.id,
|
||||
req.user.isSuperAdmin === true || req.user.permissions?.includes('class:edit') === true,
|
||||
);
|
||||
return this.service.findAll(query, classIds);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('class:view')
|
||||
findOne(@Param('id') id: string) {
|
||||
async findOne(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
|
||||
await this.assertReadAccess(req, +id);
|
||||
return this.service.findOne(+id);
|
||||
}
|
||||
|
||||
@Get(':id/schedule')
|
||||
@RequirePermission('class:view')
|
||||
getSchedule(@Param('id') id: string, @Query() query: QueryClassScheduleDto) {
|
||||
async getSchedule(
|
||||
@Param('id') id: string,
|
||||
@Query() query: QueryClassScheduleDto,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
) {
|
||||
await this.assertReadAccess(req, +id);
|
||||
return this.service.getSchedule(+id, query);
|
||||
}
|
||||
|
||||
@Get(':id/attendance-summary')
|
||||
@RequirePermission('class:view')
|
||||
getAttendanceSummary(
|
||||
async getAttendanceSummary(
|
||||
@Param('id') id: string,
|
||||
@Query() query: QueryClassAttendanceSummaryDto,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
) {
|
||||
await this.assertReadAccess(req, +id);
|
||||
return this.service.getAttendanceSummary(+id, query);
|
||||
}
|
||||
|
||||
@@ -86,14 +114,10 @@ export class ClassesController {
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
/** 批量导入学生到班级(通过钉钉用户ID) */
|
||||
@Post(':id/students/import')
|
||||
@RequirePermission('class:edit')
|
||||
async batchImportStudents(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: BatchImportStudentsDto,
|
||||
) {
|
||||
async batchImportStudents(@Param('id') id: string, @Body() dto: BatchImportStudentsDto) {
|
||||
return this.service.batchImportStudents(+id, dto.users);
|
||||
}
|
||||
|
||||
@@ -113,11 +137,7 @@ export class ClassesController {
|
||||
|
||||
@Put(':id')
|
||||
@RequirePermission('class:edit')
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateClassDto,
|
||||
@Request() req: any,
|
||||
) {
|
||||
async update(@Param('id') id: string, @Body() dto: UpdateClassDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.update(+id, dto);
|
||||
await this.logService.log({
|
||||
@@ -154,7 +174,12 @@ export class ClassesController {
|
||||
|
||||
@Get(':id/roster/export')
|
||||
@RequirePermission('class:view')
|
||||
async exportRoster(@Param('id') id: string, @Res() res: Response) {
|
||||
async exportRoster(
|
||||
@Param('id') id: string,
|
||||
@Res() res: Response,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
) {
|
||||
await this.assertReadAccess(req, +id);
|
||||
const classEntity = await this.service.findOne(+id);
|
||||
const classStudents = await this.service.getStudents(+id);
|
||||
|
||||
@@ -192,17 +217,14 @@ export class ClassesController {
|
||||
|
||||
@Get(':id/students')
|
||||
@RequirePermission('class:view')
|
||||
getStudents(@Param('id') id: string) {
|
||||
async getStudents(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
|
||||
await this.assertReadAccess(req, +id);
|
||||
return this.service.getStudents(+id);
|
||||
}
|
||||
|
||||
@Post(':id/students')
|
||||
@RequirePermission('class:edit')
|
||||
async addStudents(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: AddStudentsDto,
|
||||
@Request() req: any,
|
||||
) {
|
||||
async addStudents(@Param('id') id: string, @Body() dto: AddStudentsDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.addStudents(+id, dto.studentIds);
|
||||
await this.logService.log({
|
||||
@@ -255,17 +277,14 @@ export class ClassesController {
|
||||
|
||||
@Get(':id/teachers')
|
||||
@RequirePermission('class:view')
|
||||
getTeachers(@Param('id') id: string) {
|
||||
async getTeachers(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
|
||||
await this.assertReadAccess(req, +id);
|
||||
return this.service.getTeachers(+id);
|
||||
}
|
||||
|
||||
@Post(':id/teachers')
|
||||
@RequirePermission('class:edit')
|
||||
async addTeacher(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: AddTeacherDto,
|
||||
@Request() req: any,
|
||||
) {
|
||||
async addTeacher(@Param('id') id: string, @Body() dto: AddTeacherDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.addTeacher(+id, dto);
|
||||
await this.logService.log({
|
||||
@@ -290,6 +309,29 @@ export class ClassesController {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Delete(':id/teacher-assignments/:assignmentId')
|
||||
@RequirePermission('class:edit')
|
||||
async removeTeacherAssignment(
|
||||
@Param('id') id: string,
|
||||
@Param('assignmentId') assignmentId: string,
|
||||
@Request() req: any,
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.removeTeacherAssignment(+id, +assignmentId);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '班级管理',
|
||||
action: '移除教师角色',
|
||||
targetId: +id,
|
||||
targetType: 'class',
|
||||
detail: `移除教师分配${assignmentId}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Delete(':id/teachers/:userId')
|
||||
@RequirePermission('class:edit')
|
||||
async removeTeacher(
|
||||
|
||||
64
apps/server/src/classes/classes.scope.spec.ts
Normal file
64
apps/server/src/classes/classes.scope.spec.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { ClassesService } from './classes.service';
|
||||
|
||||
describe('ClassesService — teacher data scope', () => {
|
||||
const classRepo = { find: jest.fn() };
|
||||
const classStudentRepo = { createQueryBuilder: jest.fn() };
|
||||
const classTeacherRepo = { find: jest.fn(), findOne: jest.fn() };
|
||||
|
||||
const service = new ClassesService(
|
||||
classRepo as never,
|
||||
classStudentRepo as never,
|
||||
classTeacherRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('returns only class ids assigned to a teacher', async () => {
|
||||
classTeacherRepo.find.mockResolvedValue([{ classId: 3 }, { classId: 5 }, { classId: 3 }]);
|
||||
|
||||
await expect(service.getAccessibleClassIds(21, false)).resolves.toEqual([3, 5]);
|
||||
});
|
||||
|
||||
it('rejects access to a class outside the teacher assignments', async () => {
|
||||
classTeacherRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.assertClassAccess(21, 9, false)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it('allows class managers to access any class', async () => {
|
||||
await expect(service.assertClassAccess(21, 9, true)).resolves.toBeUndefined();
|
||||
expect(classTeacherRepo.findOne).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('clears denormalized teacher ids when the last teacher for that role is removed', async () => {
|
||||
const classRepo = { update: jest.fn() };
|
||||
const classTeacherRepo = {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
delete: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||
};
|
||||
const service = new ClassesService(
|
||||
classRepo as never,
|
||||
{} as never,
|
||||
classTeacherRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
await service.removeTeacher(8, 21);
|
||||
|
||||
expect(classRepo.update).toHaveBeenCalledWith(8, {
|
||||
headTeacherId: null,
|
||||
lifeTeacherId: null,
|
||||
academicTeacherId: null,
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,31 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, In, Like } from 'typeorm';
|
||||
import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, Classroom, Student, StudentDingMapping } from '../entities';
|
||||
import { CreateClassDto, UpdateClassDto, QueryClassDto, AddTeacherDto, QueryClassScheduleDto, QueryClassAttendanceSummaryDto, BatchImportStudentsDto } from './dto/class.dto';
|
||||
import {
|
||||
Class,
|
||||
ClassStudent,
|
||||
ClassTeacher,
|
||||
ClassSchedule,
|
||||
AttendanceRecord,
|
||||
Classroom,
|
||||
Student,
|
||||
StudentDingMapping,
|
||||
} from '../entities';
|
||||
import { normalizeDateOnly } from '../database/date-normalization';
|
||||
import {
|
||||
CreateClassDto,
|
||||
UpdateClassDto,
|
||||
QueryClassDto,
|
||||
AddTeacherDto,
|
||||
QueryClassScheduleDto,
|
||||
QueryClassAttendanceSummaryDto,
|
||||
BatchImportStudentsDto,
|
||||
} from './dto/class.dto';
|
||||
|
||||
interface RawStudentCount {
|
||||
classId: string;
|
||||
@@ -28,7 +51,19 @@ export class ClassesService {
|
||||
private studentDingMappingRepo: Repository<StudentDingMapping>,
|
||||
) {}
|
||||
|
||||
async findAll(query: QueryClassDto) {
|
||||
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 ForbiddenException('只能访问自己被分配的班级');
|
||||
}
|
||||
|
||||
async findAll(query: QueryClassDto, accessibleClassIds?: number[]) {
|
||||
let where: Record<string, unknown> = {};
|
||||
if (query.status) where.status = query.status;
|
||||
if (query.classType) where.classType = query.classType;
|
||||
@@ -36,6 +71,11 @@ export class ClassesService {
|
||||
// Default: hide archived, unless explicitly requested
|
||||
where.isArchived = query.isArchived ?? false;
|
||||
|
||||
if (accessibleClassIds) {
|
||||
if (accessibleClassIds.length === 0) return [];
|
||||
where.id = In(accessibleClassIds);
|
||||
}
|
||||
|
||||
const classes = await this.classRepo.find({
|
||||
where,
|
||||
order: { createdAt: 'DESC' as const },
|
||||
@@ -96,14 +136,21 @@ export class ClassesService {
|
||||
async create(dto: CreateClassDto) {
|
||||
const { studentIds, teachers, users, ...classData } = dto;
|
||||
|
||||
|
||||
const cls = this.classRepo.create(classData);
|
||||
const cls = this.classRepo.create({
|
||||
...classData,
|
||||
startDate: normalizeDateOnly(classData.startDate) ?? undefined,
|
||||
endDate: normalizeDateOnly(classData.endDate) ?? undefined,
|
||||
});
|
||||
const saved = await this.classRepo.save(cls);
|
||||
|
||||
// add students
|
||||
if (studentIds?.length) {
|
||||
const entries = studentIds.map((sid: number) =>
|
||||
this.classStudentRepo.create({ classId: saved.id, studentId: sid, joinDate: new Date().toISOString().split('T')[0] }),
|
||||
this.classStudentRepo.create({
|
||||
classId: saved.id,
|
||||
studentId: sid,
|
||||
joinDate: new Date().toISOString().split('T')[0],
|
||||
}),
|
||||
);
|
||||
await this.classStudentRepo.save(entries);
|
||||
}
|
||||
@@ -111,7 +158,12 @@ export class ClassesService {
|
||||
// add teachers
|
||||
if (teachers?.length) {
|
||||
const entries = teachers.map((t) =>
|
||||
this.classTeacherRepo.create({ classId: saved.id, userId: t.userId, roleType: t.roleType, subject: t.subject }),
|
||||
this.classTeacherRepo.create({
|
||||
classId: saved.id,
|
||||
userId: t.userId,
|
||||
roleType: t.roleType,
|
||||
subject: t.subject,
|
||||
}),
|
||||
);
|
||||
await this.classTeacherRepo.save(entries);
|
||||
|
||||
@@ -127,37 +179,41 @@ export class ClassesService {
|
||||
return this.findOne(saved.id);
|
||||
}
|
||||
|
||||
|
||||
async batchImportStudents(classId: number, users: Array<{
|
||||
dingUserId: string; name: string; mobile?: string;
|
||||
}>): Promise<{ imported: number; skipped: number }> {
|
||||
async batchImportStudents(
|
||||
classId: number,
|
||||
users: Array<{
|
||||
dingUserId: string;
|
||||
name: string;
|
||||
mobile?: string;
|
||||
}>,
|
||||
): Promise<{ imported: number; skipped: number }> {
|
||||
const classEntity = await this.classRepo.findOne({ where: { id: classId } });
|
||||
if (!classEntity) throw new NotFoundException('班级不存在');
|
||||
|
||||
if (users.length === 0) return { imported: 0, skipped: 0 };
|
||||
|
||||
const dingUserIds = users.map(u => u.dingUserId);
|
||||
const dingUserIds = users.map((u) => u.dingUserId);
|
||||
|
||||
// 1. Fetch all existing ding mappings in one query
|
||||
const existingMappings = await this.studentDingMappingRepo.find({
|
||||
where: { dingUserId: In(dingUserIds) },
|
||||
});
|
||||
const dingToStudentId = new Map(existingMappings.map(m => [m.dingUserId, m.studentId]));
|
||||
const dingToStudentId = new Map(existingMappings.map((m) => [m.dingUserId, m.studentId]));
|
||||
|
||||
// 2. Batch create students for new dingUserIds
|
||||
const newUsers = users.filter(u => !dingToStudentId.has(u.dingUserId));
|
||||
const newUsers = users.filter((u) => !dingToStudentId.has(u.dingUserId));
|
||||
if (newUsers.length > 0) {
|
||||
const newStudents = newUsers.map(u =>
|
||||
const newStudents = newUsers.map((u) =>
|
||||
this.studentRepo.create({
|
||||
name: u.name,
|
||||
phone: u.mobile || `dt_${u.dingUserId}`,
|
||||
status: 'active',
|
||||
})
|
||||
}),
|
||||
);
|
||||
const savedStudents = await this.studentRepo.save(newStudents);
|
||||
|
||||
const newMappings = savedStudents.map((s, i) =>
|
||||
this.studentDingMappingRepo.create({ dingUserId: newUsers[i].dingUserId, studentId: s.id })
|
||||
this.studentDingMappingRepo.create({ dingUserId: newUsers[i].dingUserId, studentId: s.id }),
|
||||
);
|
||||
await this.studentDingMappingRepo.save(newMappings);
|
||||
|
||||
@@ -180,12 +236,14 @@ export class ClassesService {
|
||||
|
||||
// 4. Batch insert new class-student records
|
||||
const newClassStudents = allStudentIds
|
||||
.filter(sid => !alreadyInClass.has(sid))
|
||||
.map(studentId =>
|
||||
.filter((sid) => !alreadyInClass.has(sid))
|
||||
.map((studentId) =>
|
||||
this.classStudentRepo.create({
|
||||
classId, studentId, status: 'active',
|
||||
classId,
|
||||
studentId,
|
||||
status: 'active',
|
||||
joinDate: new Date().toISOString().slice(0, 10),
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
if (newClassStudents.length > 0) {
|
||||
@@ -197,7 +255,15 @@ export class ClassesService {
|
||||
async update(id: number, dto: UpdateClassDto) {
|
||||
const cls = await this.classRepo.findOne({ where: { id } });
|
||||
if (!cls) throw new NotFoundException('班级不存在');
|
||||
await this.classRepo.update(id, dto);
|
||||
await this.classRepo.update(id, {
|
||||
...dto,
|
||||
...(dto.startDate !== undefined
|
||||
? { startDate: normalizeDateOnly(dto.startDate) ?? undefined }
|
||||
: {}),
|
||||
...(dto.endDate !== undefined
|
||||
? { endDate: normalizeDateOnly(dto.endDate) ?? undefined }
|
||||
: {}),
|
||||
});
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
@@ -226,7 +292,6 @@ export class ClassesService {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
|
||||
async getStudents(classId: number) {
|
||||
return this.classStudentRepo.find({
|
||||
where: { classId },
|
||||
@@ -243,7 +308,11 @@ export class ClassesService {
|
||||
const newIds = studentIds.filter((id) => !existingIds.has(id));
|
||||
|
||||
const entries = newIds.map((sid) =>
|
||||
this.classStudentRepo.create({ classId, studentId: sid, joinDate: new Date().toISOString().split('T')[0] }),
|
||||
this.classStudentRepo.create({
|
||||
classId,
|
||||
studentId: sid,
|
||||
joinDate: new Date().toISOString().split('T')[0],
|
||||
}),
|
||||
);
|
||||
if (entries.length) await this.classStudentRepo.save(entries);
|
||||
|
||||
@@ -268,7 +337,12 @@ export class ClassesService {
|
||||
});
|
||||
if (existing) throw new BadRequestException('该教师已分配此角色');
|
||||
|
||||
const entry = this.classTeacherRepo.create({ classId, userId: dto.userId, roleType: dto.roleType, subject: dto.subject });
|
||||
const entry = this.classTeacherRepo.create({
|
||||
classId,
|
||||
userId: dto.userId,
|
||||
roleType: dto.roleType,
|
||||
subject: dto.subject,
|
||||
});
|
||||
await this.classTeacherRepo.save(entry);
|
||||
|
||||
await this.syncClassTeacherIds(classId);
|
||||
@@ -281,18 +355,22 @@ export class ClassesService {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async removeTeacherAssignment(classId: number, assignmentId: number) {
|
||||
await this.classTeacherRepo.delete({ id: assignmentId, classId });
|
||||
await this.syncClassTeacherIds(classId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async syncClassTeacherIds(classId: number) {
|
||||
const teachers = await this.classTeacherRepo.find({ where: { classId } });
|
||||
const updates: Record<string, number> = {};
|
||||
const head = teachers.find((t) => t.roleType === 'head_teacher');
|
||||
const life = teachers.find((t) => t.roleType === 'life_teacher');
|
||||
const academic = teachers.find((t) => t.roleType === 'academic_teacher');
|
||||
if (head) updates.headTeacherId = head.userId;
|
||||
if (life) updates.lifeTeacherId = life.userId;
|
||||
if (academic) updates.academicTeacherId = academic.userId;
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await this.classRepo.update(classId, updates);
|
||||
}
|
||||
await this.classRepo.update(classId, {
|
||||
headTeacherId: head?.userId ?? null,
|
||||
lifeTeacherId: life?.userId ?? null,
|
||||
academicTeacherId: academic?.userId ?? null,
|
||||
} as Partial<Class>);
|
||||
}
|
||||
|
||||
async getSchedule(classId: number, query: QueryClassScheduleDto) {
|
||||
|
||||
@@ -58,6 +58,33 @@ export class ClassroomRentalsController {
|
||||
return this.service.getSchedule(y, m);
|
||||
}
|
||||
|
||||
@Get('unavailable-dates')
|
||||
@RequirePermission('rental:view')
|
||||
getUnavailableDates(
|
||||
@Query('classroomId') classroomId?: string,
|
||||
@Query('year') year?: string,
|
||||
@Query('month') month?: string,
|
||||
@Query('excludeId') excludeId?: string,
|
||||
) {
|
||||
const parsedClassroomId = Number(classroomId);
|
||||
const parsedYear = Number(year);
|
||||
const parsedMonth = Number(month);
|
||||
if (!Number.isInteger(parsedClassroomId) || parsedClassroomId <= 0) {
|
||||
throw new BadRequestException('请选择有效教室');
|
||||
}
|
||||
if (!Number.isInteger(parsedYear) || parsedYear < 2000 || parsedYear > 2100) {
|
||||
throw new BadRequestException('年份不合法');
|
||||
}
|
||||
if (!Number.isInteger(parsedMonth) || parsedMonth < 1 || parsedMonth > 12) {
|
||||
throw new BadRequestException('月份必须在 1-12 之间');
|
||||
}
|
||||
const parsedExcludeId = excludeId === undefined ? undefined : Number(excludeId);
|
||||
if (parsedExcludeId !== undefined && (!Number.isInteger(parsedExcludeId) || parsedExcludeId <= 0)) {
|
||||
throw new BadRequestException('排除的租赁订单不合法');
|
||||
}
|
||||
return this.service.getUnavailableDates(parsedClassroomId, parsedYear, parsedMonth, parsedExcludeId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('rental:view')
|
||||
findOne(@Param('id') id: string) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Not, Repository } from 'typeorm';
|
||||
import { ClassroomRentalsService } from './classroom-rentals.service';
|
||||
import { ClassroomRental } from '../entities/classroom-rental.entity';
|
||||
import { Classroom } from '../entities/classroom.entity';
|
||||
@@ -56,7 +56,7 @@ describe('ClassroomRentalsService — findConflicts', () => {
|
||||
it('throws ConflictException when an active schedule overlaps the same classroom and date range', async () => {
|
||||
const rentalQb = mockQueryBuilder<ClassroomRental>([]);
|
||||
const scheduleQb = mockQueryBuilder<ClassSchedule>([
|
||||
{ id: 5, subject: '数学', startDate: '2026-03-01', endDate: '2026-06-30' } as ClassSchedule,
|
||||
{ id: 5, subject: '数学', weekDay: 1, startDate: '2026-03-01', endDate: '2026-06-30' } as ClassSchedule,
|
||||
]);
|
||||
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb);
|
||||
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(scheduleQb);
|
||||
@@ -64,6 +64,19 @@ describe('ClassroomRentalsService — findConflicts', () => {
|
||||
await expect(service.findConflicts(1, '2026-03-15', '2026-04-15')).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it('does not treat a weekly schedule as a conflict when its weekday does not occur in the rental range', async () => {
|
||||
const rentalQb = mockQueryBuilder<ClassroomRental>([]);
|
||||
const scheduleQb = mockQueryBuilder<ClassSchedule>([
|
||||
{ id: 5, subject: '数学', weekDay: 1, startDate: '2026-07-01', endDate: '2026-07-31' } as ClassSchedule,
|
||||
]);
|
||||
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb);
|
||||
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(scheduleQb);
|
||||
|
||||
const result = await service.findConflicts(1, '2026-07-01', '2026-07-05');
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not throw when schedule is outside the requested date range', async () => {
|
||||
const rentalQb = mockQueryBuilder<ClassroomRental>([]);
|
||||
const scheduleQb = mockQueryBuilder<ClassSchedule>([]);
|
||||
@@ -87,6 +100,54 @@ describe('ClassroomRentalsService — findConflicts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('ClassroomRentalsService — unavailable dates', () => {
|
||||
let service: ClassroomRentalsService;
|
||||
let rentalRepo: jest.Mocked<Pick<Repository<ClassroomRental>, 'find'>>;
|
||||
let scheduleRepo: jest.Mocked<Pick<Repository<ClassSchedule>, 'find'>>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ClassroomRentalsService,
|
||||
{ provide: getRepositoryToken(ClassroomRental), useValue: { find: jest.fn() } },
|
||||
{ provide: getRepositoryToken(Classroom), useValue: {} },
|
||||
{ provide: getRepositoryToken(Tenant), useValue: {} },
|
||||
{ provide: getRepositoryToken(ClassSchedule), useValue: { find: jest.fn() } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ClassroomRentalsService>(ClassroomRentalsService);
|
||||
rentalRepo = module.get(getRepositoryToken(ClassroomRental));
|
||||
scheduleRepo = module.get(getRepositoryToken(ClassSchedule));
|
||||
});
|
||||
|
||||
it('returns rental days and actual weekly schedule occurrence dates for a month', async () => {
|
||||
rentalRepo.find.mockResolvedValue([
|
||||
{ id: 10, startDate: '2026-07-03', endDate: '2026-07-04' } as ClassroomRental,
|
||||
]);
|
||||
scheduleRepo.find.mockResolvedValue([
|
||||
{ id: 5, weekDay: 1, startDate: '2026-07-01', endDate: '2026-07-31' } as ClassSchedule,
|
||||
]);
|
||||
|
||||
const result = await service.getUnavailableDates(1, 2026, 7);
|
||||
|
||||
expect(result).toEqual({
|
||||
dates: ['2026-07-03', '2026-07-04', '2026-07-06', '2026-07-13', '2026-07-20', '2026-07-27'],
|
||||
});
|
||||
});
|
||||
|
||||
it('excludes the rental being edited', async () => {
|
||||
rentalRepo.find.mockResolvedValue([]);
|
||||
scheduleRepo.find.mockResolvedValue([]);
|
||||
|
||||
await service.getUnavailableDates(1, 2026, 7, 99);
|
||||
|
||||
expect(rentalRepo.find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: expect.objectContaining({ id: Not(99) }) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
let service: ClassroomRentalsService;
|
||||
let rentalRepo: jest.Mocked<
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Not } from 'typeorm';
|
||||
import { Repository, Not, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
||||
import { ClassroomRental } from '../entities/classroom-rental.entity';
|
||||
import { Classroom } from '../entities/classroom.entity';
|
||||
import { Tenant } from '../entities/tenant.entity';
|
||||
@@ -80,6 +80,47 @@ export class ClassroomRentalsService {
|
||||
return rental;
|
||||
}
|
||||
|
||||
async getUnavailableDates(classroomId: number, year: number, month: number, excludeId?: number) {
|
||||
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||
const monthStart = `${year}-${String(month).padStart(2, '0')}-01`;
|
||||
const monthEnd = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
const [rentals, schedules] = await Promise.all([
|
||||
this.repo.find({
|
||||
where: {
|
||||
...(excludeId ? { id: Not(excludeId) } : {}),
|
||||
classroomId,
|
||||
status: Not('cancelled'),
|
||||
startDate: LessThanOrEqual(monthEnd),
|
||||
endDate: MoreThanOrEqual(monthStart),
|
||||
},
|
||||
}),
|
||||
this.scheduleRepo.find({
|
||||
where: {
|
||||
classroomId,
|
||||
status: 'active',
|
||||
scheduleType: 'INTERNAL',
|
||||
startDate: LessThanOrEqual(monthEnd),
|
||||
endDate: MoreThanOrEqual(monthStart),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const unavailableDates = new Set<string>();
|
||||
for (const rental of rentals) {
|
||||
this.addDateRange(
|
||||
unavailableDates,
|
||||
rental.startDate > monthStart ? rental.startDate : monthStart,
|
||||
rental.endDate < monthEnd ? rental.endDate : monthEnd,
|
||||
);
|
||||
}
|
||||
for (const schedule of schedules) {
|
||||
this.addScheduleOccurrences(unavailableDates, schedule, monthStart, monthEnd);
|
||||
}
|
||||
|
||||
return { dates: Array.from(unavailableDates).sort() };
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找与给定区间冲突的租赁订单,同时检测同一教室同一日期段的内部排课
|
||||
* 重叠判定:start1 <= end2 AND start2 <= end1
|
||||
@@ -96,13 +137,17 @@ export class ClassroomRentalsService {
|
||||
const rentals = await qb.getMany();
|
||||
|
||||
// 检测同一教室同一日期段是否存在内部排课
|
||||
const scheduleConflicts = await this.scheduleRepo
|
||||
const scheduleCandidates = await this.scheduleRepo
|
||||
.createQueryBuilder('cs')
|
||||
.where('cs.classroomId = :cid', { cid: classroomId })
|
||||
.andWhere('cs.status = :status', { status: 'active' })
|
||||
.andWhere('cs.scheduleType = :scheduleType', { scheduleType: 'INTERNAL' })
|
||||
.andWhere('cs.startDate <= :end', { end: endDate })
|
||||
.andWhere('cs.endDate >= :start', { start: startDate })
|
||||
.getMany();
|
||||
const scheduleConflicts = scheduleCandidates.filter((schedule) =>
|
||||
this.hasScheduleOccurrence(schedule, startDate, endDate),
|
||||
);
|
||||
|
||||
if (scheduleConflicts.length > 0) {
|
||||
throw new ConflictException({
|
||||
@@ -119,6 +164,48 @@ export class ClassroomRentalsService {
|
||||
return rentals;
|
||||
}
|
||||
|
||||
private hasScheduleOccurrence(schedule: ClassSchedule, startDate: string, endDate: string): boolean {
|
||||
const overlapStart = schedule.startDate > startDate ? schedule.startDate : startDate;
|
||||
const overlapEnd = schedule.endDate < endDate ? schedule.endDate : endDate;
|
||||
if (overlapStart > overlapEnd) return false;
|
||||
|
||||
const startUtc = this.toUtcDate(overlapStart);
|
||||
const endUtc = this.toUtcDate(overlapEnd);
|
||||
const startWeekDay = startUtc.getUTCDay() || 7;
|
||||
const daysUntilOccurrence = (schedule.weekDay - startWeekDay + 7) % 7;
|
||||
startUtc.setUTCDate(startUtc.getUTCDate() + daysUntilOccurrence);
|
||||
return startUtc <= endUtc;
|
||||
}
|
||||
|
||||
private toUtcDate(date: string): Date {
|
||||
const [year, month, day] = date.split('-').map(Number);
|
||||
return new Date(Date.UTC(year, month - 1, day));
|
||||
}
|
||||
|
||||
private addDateRange(dates: Set<string>, startDate: string, endDate: string) {
|
||||
const current = this.toUtcDate(startDate);
|
||||
const end = this.toUtcDate(endDate);
|
||||
while (current <= end) {
|
||||
dates.add(current.toISOString().slice(0, 10));
|
||||
current.setUTCDate(current.getUTCDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private addScheduleOccurrences(dates: Set<string>, schedule: ClassSchedule, startDate: string, endDate: string) {
|
||||
const overlapStart = schedule.startDate > startDate ? schedule.startDate : startDate;
|
||||
const overlapEnd = schedule.endDate < endDate ? schedule.endDate : endDate;
|
||||
if (overlapStart > overlapEnd) return;
|
||||
|
||||
const current = this.toUtcDate(overlapStart);
|
||||
const end = this.toUtcDate(overlapEnd);
|
||||
const startWeekDay = current.getUTCDay() || 7;
|
||||
current.setUTCDate(current.getUTCDate() + ((schedule.weekDay - startWeekDay + 7) % 7));
|
||||
while (current <= end) {
|
||||
dates.add(current.toISOString().slice(0, 10));
|
||||
current.setUTCDate(current.getUTCDate() + 7);
|
||||
}
|
||||
}
|
||||
|
||||
async create(dto: CreateRentalDto, userId?: number) {
|
||||
if (dto.startDate > dto.endDate) throw new BadRequestException('起始日期不能晚于结束日期');
|
||||
const classroom = await this.classroomRepo.findOne({ where: { id: dto.classroomId } });
|
||||
|
||||
@@ -1,17 +1,36 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { Controller, Get, Query, Request, UseGuards } from '@nestjs/common';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
|
||||
interface RequestUser {
|
||||
id: number;
|
||||
username: string;
|
||||
permissions?: string[];
|
||||
isSuperAdmin?: boolean;
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@RequirePermission('dashboard:view')
|
||||
@Controller('dashboard')
|
||||
export class DashboardController {
|
||||
constructor(private service: DashboardService) {}
|
||||
|
||||
private canManageAllDashboard(user: RequestUser): boolean {
|
||||
return (
|
||||
user.isSuperAdmin === true ||
|
||||
user.permissions?.includes('dashboard:manage') === true ||
|
||||
user.permissions?.includes('class:edit') === true
|
||||
);
|
||||
}
|
||||
|
||||
private getAccessibleClassIds(user: RequestUser) {
|
||||
return this.service.getAccessibleClassIds(user.id, this.canManageAllDashboard(user));
|
||||
}
|
||||
|
||||
@Get('stats')
|
||||
getStats() {
|
||||
return this.service.getStats();
|
||||
async getStats(@Request() req: { user: RequestUser }) {
|
||||
return this.service.getStats(await this.getAccessibleClassIds(req.user));
|
||||
}
|
||||
|
||||
@Get('gantt')
|
||||
@@ -40,8 +59,8 @@ export class DashboardController {
|
||||
}
|
||||
|
||||
@Get('class-attendance-ranking')
|
||||
getClassAttendanceRanking() {
|
||||
return this.service.getClassAttendanceRanking();
|
||||
async getClassAttendanceRanking(@Request() req: { user: RequestUser }) {
|
||||
return this.service.getClassAttendanceRanking(await this.getAccessibleClassIds(req.user));
|
||||
}
|
||||
|
||||
@Get('classroom-occupancy')
|
||||
@@ -53,4 +72,4 @@ export class DashboardController {
|
||||
async getClassroomUtilization() {
|
||||
return this.service.getClassroomUtilizationStats();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,28 @@ import { Class } from '../entities/class.entity';
|
||||
import { Deposit } from '../entities/deposit.entity';
|
||||
import { ClassroomRental } from '../entities/classroom-rental.entity';
|
||||
import { ClassTeacher } from '../entities/class-teacher.entity';
|
||||
import { ClassStudent } from '../entities/class-student.entity';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
import { DashboardController } from './dashboard.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Room, Student, Occupancy, Bill, RoomExpense, Classroom, ClassSchedule, AttendanceRecord, Class, Deposit, ClassroomRental, ClassTeacher])],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Room,
|
||||
Student,
|
||||
Occupancy,
|
||||
Bill,
|
||||
RoomExpense,
|
||||
Classroom,
|
||||
ClassSchedule,
|
||||
AttendanceRecord,
|
||||
Class,
|
||||
Deposit,
|
||||
ClassroomRental,
|
||||
ClassTeacher,
|
||||
ClassStudent,
|
||||
]),
|
||||
],
|
||||
controllers: [DashboardController],
|
||||
providers: [DashboardService],
|
||||
})
|
||||
|
||||
44
apps/server/src/dashboard/dashboard.scope.spec.ts
Normal file
44
apps/server/src/dashboard/dashboard.scope.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { DashboardService } from './dashboard.service';
|
||||
|
||||
const createQb = () => ({
|
||||
leftJoin: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
groupBy: jest.fn().mockReturnThis(),
|
||||
addGroupBy: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
getRawMany: jest.fn().mockResolvedValue([]),
|
||||
getRawOne: jest.fn().mockResolvedValue({ cnt: '0' }),
|
||||
});
|
||||
|
||||
describe('DashboardService — teacher class scope', () => {
|
||||
it('filters class attendance ranking by assigned classes', async () => {
|
||||
const qb = createQb();
|
||||
const attendanceRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };
|
||||
const service = new DashboardService(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
attendanceRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
await service.getClassAttendanceRanking([8, 9]);
|
||||
|
||||
expect(qb.andWhere).toHaveBeenCalledWith('a.classId IN (:...accessibleClassIds)', {
|
||||
accessibleClassIds: [8, 9],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, IsNull, Not, MoreThanOrEqual } from 'typeorm';
|
||||
import { Repository, IsNull, Not, MoreThanOrEqual, In } from 'typeorm';
|
||||
import { Room } from '../entities/room.entity';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { Occupancy } from '../entities/occupancy.entity';
|
||||
@@ -13,11 +13,10 @@ import { Class } from '../entities/class.entity';
|
||||
import { Deposit } from '../entities/deposit.entity';
|
||||
import { ClassroomRental } from '../entities/classroom-rental.entity';
|
||||
import { ClassTeacher } from '../entities/class-teacher.entity';
|
||||
|
||||
import { ClassStudent } from '../entities/class-student.entity';
|
||||
|
||||
@Injectable()
|
||||
export class DashboardService {
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Room) private roomRepo: Repository<Room>,
|
||||
@InjectRepository(Student) private studentRepo: Repository<Student>,
|
||||
@@ -31,15 +30,24 @@ export class DashboardService {
|
||||
@InjectRepository(Deposit) private depositRepo: Repository<Deposit>,
|
||||
@InjectRepository(ClassroomRental) private rentalRepo: Repository<ClassroomRental>,
|
||||
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
|
||||
@InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
|
||||
) {}
|
||||
|
||||
async getStats() {
|
||||
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 getStats(accessibleClassIds?: number[]) {
|
||||
const today = new Date();
|
||||
const todayStr = today.toISOString().slice(0, 10);
|
||||
const currentMonth = todayStr.slice(0, 7); // YYYY-MM
|
||||
|
||||
const totalRooms = await this.roomRepo.count({ where: { status: Not('archived') } });
|
||||
const totalStudents = await this.studentRepo.count({ where: { status: 'active' } });
|
||||
const totalStudents = accessibleClassIds
|
||||
? await this.countStudentsInClasses(accessibleClassIds)
|
||||
: await this.studentRepo.count({ where: { status: 'active' } });
|
||||
const occupiedBeds = await this.occRepo.count({ where: { checkOutDate: IsNull() } });
|
||||
const capQb = this.roomRepo
|
||||
.createQueryBuilder('r')
|
||||
@@ -68,24 +76,22 @@ export class DashboardService {
|
||||
.andWhere('s.endDate >= :today', { today: todayStr });
|
||||
const occResult = await occQb.getRawOne();
|
||||
const occupiedClassrooms = parseInt(occResult?.cnt || '0', 10);
|
||||
const classroomOccupancyRate = classroomCount > 0
|
||||
? ((occupiedClassrooms / classroomCount) * 100).toFixed(1)
|
||||
: 0;
|
||||
const classroomOccupancyRate =
|
||||
classroomCount > 0 ? ((occupiedClassrooms / classroomCount) * 100).toFixed(1) : 0;
|
||||
|
||||
const attTodayQb = this.attendanceRepo
|
||||
.createQueryBuilder('a')
|
||||
.select('a.status', 'status')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('a.attendanceDate = :today', { today: todayStr })
|
||||
.groupBy('a.status');
|
||||
.where('a.attendanceDate = :today', { today: todayStr });
|
||||
this.applyClassScope(attTodayQb, 'a', accessibleClassIds);
|
||||
attTodayQb.groupBy('a.status');
|
||||
const attTodayStats = await attTodayQb.getRawMany();
|
||||
const todayTotal = attTodayStats.reduce((sum, r) => sum + parseInt(r.count, 10), 0);
|
||||
const todayPresent = attTodayStats
|
||||
.filter((r) => r.status === 'present')
|
||||
.reduce((sum, r) => sum + parseInt(r.count, 10), 0);
|
||||
const todayAttendanceRate = todayTotal > 0
|
||||
? ((todayPresent / todayTotal) * 100).toFixed(1)
|
||||
: 0;
|
||||
const todayAttendanceRate = todayTotal > 0 ? ((todayPresent / todayTotal) * 100).toFixed(1) : 0;
|
||||
|
||||
const incomeQb = this.billRepo
|
||||
.createQueryBuilder('b')
|
||||
@@ -96,11 +102,13 @@ export class DashboardService {
|
||||
const incomeResult = await incomeQb.getRawOne();
|
||||
const monthlyIncome = parseFloat(incomeResult?.total || '0');
|
||||
|
||||
const attendanceTrend = await this.getAttendanceTrend(todayStr);
|
||||
const attendanceTrend = await this.getAttendanceTrend(todayStr, accessibleClassIds);
|
||||
const incomeTrend = await this.getIncomeTrend(currentMonth);
|
||||
|
||||
// --- New stats ---
|
||||
const classCount = await this.classRepo.count({ where: {} });
|
||||
const classCount = accessibleClassIds
|
||||
? accessibleClassIds.length
|
||||
: await this.classRepo.count({ where: {} });
|
||||
|
||||
const teacherResult = await this.classTeacherRepo
|
||||
.createQueryBuilder('ct')
|
||||
@@ -116,7 +124,9 @@ export class DashboardService {
|
||||
const pendingResult = await pendingQb.getRawOne();
|
||||
const pendingDeposits = parseFloat(pendingResult?.total || '0');
|
||||
|
||||
const activeRentals = await this.rentalRepo.count({ where: { endDate: MoreThanOrEqual(todayStr) } });
|
||||
const activeRentals = await this.rentalRepo.count({
|
||||
where: { endDate: MoreThanOrEqual(todayStr) },
|
||||
});
|
||||
|
||||
const occByBldQb = this.occRepo
|
||||
.createQueryBuilder('o')
|
||||
@@ -126,10 +136,13 @@ export class DashboardService {
|
||||
.where('o.checkOutDate IS NULL');
|
||||
const occupancyByBuilding = await occByBldQb.groupBy('r.building').getRawMany();
|
||||
|
||||
const attendanceByStatus = attTodayStats.reduce((acc, r) => {
|
||||
acc[r.status] = parseInt(r.count, 10);
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
const attendanceByStatus = attTodayStats.reduce(
|
||||
(acc, r) => {
|
||||
acc[r.status] = parseInt(r.count, 10);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
);
|
||||
|
||||
const expByTypeQb = this.expRepo
|
||||
.createQueryBuilder('e')
|
||||
@@ -162,18 +175,39 @@ export class DashboardService {
|
||||
};
|
||||
}
|
||||
|
||||
private async getAttendanceTrend(todayStr: string) {
|
||||
private applyClassScope(
|
||||
qb: { andWhere: (condition: string, parameters?: Record<string, unknown>) => unknown },
|
||||
alias: string,
|
||||
accessibleClassIds?: number[],
|
||||
) {
|
||||
if (accessibleClassIds) {
|
||||
qb.andWhere(`${alias}.classId IN (:...accessibleClassIds)`, { accessibleClassIds });
|
||||
}
|
||||
}
|
||||
|
||||
private async countStudentsInClasses(accessibleClassIds: number[]) {
|
||||
if (accessibleClassIds.length === 0) return 0;
|
||||
const classStudents = await this.classStudentRepo.find({
|
||||
where: { classId: In(accessibleClassIds), status: 'active' },
|
||||
});
|
||||
return new Set(classStudents.map((item) => item.studentId)).size;
|
||||
}
|
||||
|
||||
private async getAttendanceTrend(todayStr: string, accessibleClassIds?: number[]) {
|
||||
const thirtyDaysAgo = new Date(todayStr);
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 29);
|
||||
const startStr = thirtyDaysAgo.toISOString().slice(0, 10);
|
||||
|
||||
const rows = await this.attendanceRepo
|
||||
const trendQb = this.attendanceRepo
|
||||
.createQueryBuilder('a')
|
||||
.select('a.attendanceDate', 'date')
|
||||
.addSelect('a.status', 'status')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('a.attendanceDate >= :start', { start: startStr })
|
||||
.andWhere('a.attendanceDate <= :today', { today: todayStr })
|
||||
.andWhere('a.attendanceDate <= :today', { today: todayStr });
|
||||
this.applyClassScope(trendQb, 'a', accessibleClassIds);
|
||||
|
||||
const rows = await trendQb
|
||||
.groupBy('a.attendanceDate')
|
||||
.addGroupBy('a.status')
|
||||
.orderBy('a.attendanceDate', 'ASC')
|
||||
@@ -235,7 +269,6 @@ export class DashboardService {
|
||||
.orderBy('room.roomNumber', 'ASC')
|
||||
.addOrderBy('o.checkInDate', 'ASC');
|
||||
|
||||
|
||||
if (query?.building) {
|
||||
qb.andWhere('room.building = :building', { building: query.building });
|
||||
}
|
||||
@@ -297,21 +330,24 @@ export class DashboardService {
|
||||
}
|
||||
|
||||
// 班级考勤排行
|
||||
async getClassAttendanceRanking() {
|
||||
async getClassAttendanceRanking(accessibleClassIds?: number[]) {
|
||||
if (accessibleClassIds?.length === 0) return { top: [], bottom: [] };
|
||||
const qb = this.attendanceRepo
|
||||
.createQueryBuilder('a')
|
||||
.leftJoin('a.class', 'class')
|
||||
.select('class.id', 'classId')
|
||||
.addSelect('class.name', 'className')
|
||||
.addSelect('a.status', 'status')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.groupBy('class.id').addGroupBy('class.name').addGroupBy('a.status');
|
||||
.addSelect('COUNT(*)', 'count');
|
||||
this.applyClassScope(qb, 'a', accessibleClassIds);
|
||||
qb.groupBy('class.id').addGroupBy('class.name').addGroupBy('a.status');
|
||||
const raw = await qb.getRawMany();
|
||||
|
||||
const classMap = new Map<number, { className: string; present: number; total: number }>();
|
||||
for (const r of raw) {
|
||||
if (!r.classId) continue;
|
||||
if (!classMap.has(Number(r.classId))) classMap.set(Number(r.classId), { className: r.className, present: 0, total: 0 });
|
||||
if (!classMap.has(Number(r.classId)))
|
||||
classMap.set(Number(r.classId), { className: r.className, present: 0, total: 0 });
|
||||
const entry = classMap.get(Number(r.classId))!;
|
||||
const n = parseInt(r.count, 10);
|
||||
entry.total += n;
|
||||
@@ -319,7 +355,10 @@ export class DashboardService {
|
||||
}
|
||||
|
||||
const ranked = Array.from(classMap.values())
|
||||
.map(e => ({ ...e, rate: e.total > 0 ? parseFloat(((e.present / e.total) * 100).toFixed(1)) : 0 }))
|
||||
.map((e) => ({
|
||||
...e,
|
||||
rate: e.total > 0 ? parseFloat(((e.present / e.total) * 100).toFixed(1)) : 0,
|
||||
}))
|
||||
.sort((a, b) => b.rate - a.rate);
|
||||
|
||||
return { top: ranked.slice(0, 5), bottom: ranked.slice(-5).reverse() };
|
||||
@@ -412,9 +451,8 @@ export class DashboardService {
|
||||
const scheduleCount = parseInt(schedResult?.cnt || '0', 10);
|
||||
const rentalCount = parseInt(rentalResult?.cnt || '0', 10);
|
||||
const inUseCount = allInUseIds.size;
|
||||
const utilizationRate = totalClassrooms > 0
|
||||
? ((inUseCount / totalClassrooms) * 100).toFixed(1)
|
||||
: '0';
|
||||
const utilizationRate =
|
||||
totalClassrooms > 0 ? ((inUseCount / totalClassrooms) * 100).toFixed(1) : '0';
|
||||
|
||||
return {
|
||||
totalClassrooms,
|
||||
@@ -424,4 +462,4 @@ export class DashboardService {
|
||||
rentalCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
5
apps/server/src/database/database-migrations.module.ts
Normal file
5
apps/server/src/database/database-migrations.module.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DatabaseMigrationsService } from './database-migrations.service';
|
||||
|
||||
@Module({ providers: [DatabaseMigrationsService] })
|
||||
export class DatabaseMigrationsModule {}
|
||||
41
apps/server/src/database/database-migrations.service.ts
Normal file
41
apps/server/src/database/database-migrations.service.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
@Injectable()
|
||||
export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(DatabaseMigrationsService.name);
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
await this.normalizeClassDates();
|
||||
}
|
||||
|
||||
private async normalizeClassDates(): Promise<void> {
|
||||
const driver = this.dataSource.options.type;
|
||||
const dateExpression = (column: string) =>
|
||||
driver === 'mysql' ? `DATE(${column})` : `substr(${column}, 1, 10)`;
|
||||
|
||||
const lengthFunction = driver === 'mysql' ? 'CHAR_LENGTH' : 'length';
|
||||
const result = await this.dataSource.transaction((manager) =>
|
||||
manager.query(`
|
||||
UPDATE classes
|
||||
SET
|
||||
start_date = CASE
|
||||
WHEN start_date IS NULL OR start_date = '' THEN start_date
|
||||
ELSE ${dateExpression('start_date')}
|
||||
END,
|
||||
end_date = CASE
|
||||
WHEN end_date IS NULL OR end_date = '' THEN end_date
|
||||
ELSE ${dateExpression('end_date')}
|
||||
END
|
||||
WHERE
|
||||
(start_date IS NOT NULL AND ${lengthFunction}(start_date) > 10)
|
||||
OR (end_date IS NOT NULL AND ${lengthFunction}(end_date) > 10)
|
||||
`),
|
||||
);
|
||||
|
||||
const affected = typeof result?.changes === 'number' ? result.changes : result?.affectedRows;
|
||||
if (affected) this.logger.log(`已规范化 ${affected} 条班级日期数据`);
|
||||
}
|
||||
}
|
||||
15
apps/server/src/database/date-normalization.spec.ts
Normal file
15
apps/server/src/database/date-normalization.spec.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { normalizeDateOnly } from './date-normalization';
|
||||
|
||||
describe('normalizeDateOnly', () => {
|
||||
it('keeps date-only values unchanged', () => {
|
||||
expect(normalizeDateOnly('2026-07-02')).toBe('2026-07-02');
|
||||
});
|
||||
|
||||
it('converts legacy ISO timestamps to their UTC calendar date', () => {
|
||||
expect(normalizeDateOnly('2026-07-01T16:00:00.000Z')).toBe('2026-07-01');
|
||||
});
|
||||
|
||||
it('rejects unsupported date formats', () => {
|
||||
expect(() => normalizeDateOnly('07/01/2026')).toThrow('无效日期格式');
|
||||
});
|
||||
});
|
||||
15
apps/server/src/database/date-normalization.ts
Normal file
15
apps/server/src/database/date-normalization.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const ISO_DATE_PREFIX_PATTERN = /^(\d{4}-\d{2}-\d{2})T/;
|
||||
|
||||
export function normalizeDateOnly(value?: string | null): string | null | undefined {
|
||||
if (value == null || value === '') return value;
|
||||
if (DATE_ONLY_PATTERN.test(value)) return value;
|
||||
|
||||
const isoPrefix = ISO_DATE_PREFIX_PATTERN.exec(value)?.[1];
|
||||
if (isoPrefix) {
|
||||
const date = new Date(value);
|
||||
if (!Number.isNaN(date.getTime())) return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
throw new Error(`无效日期格式: ${value}`);
|
||||
}
|
||||
@@ -18,6 +18,9 @@ export class Role {
|
||||
@Column({ type: 'varchar', length: 30, unique: true })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 30, unique: true, nullable: true })
|
||||
code: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 200, nullable: true })
|
||||
description: string;
|
||||
|
||||
|
||||
174
apps/server/src/integration/dingtalk.service.spec.ts
Normal file
174
apps/server/src/integration/dingtalk.service.spec.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { DingTalkService } from './dingtalk.service';
|
||||
|
||||
describe('DingTalkService — queryShifts', () => {
|
||||
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,
|
||||
});
|
||||
jest.spyOn(service as never, 'rateLimit').mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
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('unwraps the paged result object returned by DingTalk', async () => {
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
json: jest.fn().mockResolvedValue({
|
||||
errcode: 0,
|
||||
errmsg: 'ok',
|
||||
result: {
|
||||
cursor: 678215070,
|
||||
has_more: false,
|
||||
result: [
|
||||
{ id: 677995086, name: 'A' },
|
||||
{ id: 678215070, name: 'B' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
}) as jest.MockedFunction<typeof fetch>;
|
||||
|
||||
await expect(service.queryShifts('manager')).resolves.toEqual([
|
||||
{ id: 677995086, name: 'A' },
|
||||
{ id: 678215070, name: 'B' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('requests subsequent pages using the cursor returned by DingTalk', async () => {
|
||||
global.fetch = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
json: jest.fn().mockResolvedValue({
|
||||
errcode: 0,
|
||||
errmsg: 'ok',
|
||||
result: {
|
||||
cursor: 200,
|
||||
has_more: true,
|
||||
result: [{ id: 100, name: '早班' }],
|
||||
},
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
json: jest.fn().mockResolvedValue({
|
||||
errcode: 0,
|
||||
errmsg: 'ok',
|
||||
result: {
|
||||
cursor: 300,
|
||||
has_more: false,
|
||||
result: [{ id: 200, name: '晚班' }],
|
||||
},
|
||||
}),
|
||||
}) as jest.MockedFunction<typeof fetch>;
|
||||
|
||||
await expect(service.queryShifts('manager')).resolves.toEqual([
|
||||
{ id: 100, name: '早班' },
|
||||
{ id: 200, name: '晚班' },
|
||||
]);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
expect(JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body)).toEqual({
|
||||
op_user_id: 'manager',
|
||||
cursor: 0,
|
||||
});
|
||||
expect(JSON.parse((global.fetch as jest.Mock).mock.calls[1][1].body)).toEqual({
|
||||
op_user_id: 'manager',
|
||||
cursor: 200,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DingTalkService — attendance machine only group', () => {
|
||||
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,
|
||||
});
|
||||
jest.spyOn(service as never, 'rateLimit').mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
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('accepts the success envelope returned when updating an attendance group', async () => {
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
json: jest.fn().mockResolvedValue({
|
||||
success: true,
|
||||
result: { id: 123, name: '排课_冲刺班' },
|
||||
request_id: 'request-1',
|
||||
}),
|
||||
}) as jest.MockedFunction<typeof fetch>;
|
||||
|
||||
await expect(
|
||||
service.updateAttendanceGroup({
|
||||
id: 123,
|
||||
name: '排课_冲刺班',
|
||||
type: 'TURN',
|
||||
owner: 'manager',
|
||||
members: [{ role: 'Attendance', type: 'StaffMember', user_id: 'student-1' }],
|
||||
shift_ids: [456],
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('disables mobile-oriented punching when creating a machine-only group', async () => {
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
json: jest.fn().mockResolvedValue({
|
||||
errcode: 0,
|
||||
errmsg: 'ok',
|
||||
result: { id: 123 },
|
||||
}),
|
||||
}) as jest.MockedFunction<typeof fetch>;
|
||||
|
||||
await service.createAttendanceGroup({
|
||||
name: '排课_冲刺班',
|
||||
type: 'TURN',
|
||||
owner: 'manager',
|
||||
members: [{ role: 'Attendance', type: 'StaffMember', user_id: 'student-1' }],
|
||||
shift_ids: [456],
|
||||
attendance_machine_only: true,
|
||||
});
|
||||
|
||||
const body = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body);
|
||||
expect(body.top_group).toEqual(expect.objectContaining({
|
||||
enable_emp_select_class: false,
|
||||
disable_check_without_schedule: true,
|
||||
enable_outside_check: false,
|
||||
enable_position_ble: false,
|
||||
positions: [],
|
||||
wifis: [],
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -126,6 +126,13 @@ export interface DingTalkGroupParams {
|
||||
enable_emp_select_class?: boolean;
|
||||
disable_check_without_schedule?: boolean;
|
||||
disable_check_when_rest?: boolean;
|
||||
/** 关闭外勤、定位、Wi-Fi 和手机蓝牙打卡,仅保留考勤机打卡入口 */
|
||||
attendance_machine_only?: boolean;
|
||||
}
|
||||
|
||||
/** 修改考勤组参数 */
|
||||
export interface DingTalkGroupUpdateParams extends DingTalkGroupParams {
|
||||
id: number;
|
||||
}
|
||||
|
||||
/** 考勤组摘要(查询返回) */
|
||||
@@ -433,10 +440,10 @@ export class DingTalkService {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
userIds?: string[];
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
}): Promise<DingTalkAttendanceResult[]> {
|
||||
if (!this.configured) throw new Error('DingTalk not configured');
|
||||
if (!params.userIds?.length) throw new Error('钉钉考勤 userIds 不能为空');
|
||||
if (params.userIds.length > 50) throw new Error('钉钉考勤单次最多查询50人');
|
||||
const token = await this.getAccessToken();
|
||||
|
||||
const dateFrom = params.startDate.includes(' ') ? params.startDate : `${params.startDate} 00:00:00`;
|
||||
@@ -446,9 +453,7 @@ export class DingTalkService {
|
||||
checkDateFrom: dateFrom,
|
||||
checkDateTo: dateTo,
|
||||
};
|
||||
if (params.userIds?.length) body.userIds = params.userIds;
|
||||
if (params.offset !== undefined) body.offset = params.offset;
|
||||
if (params.limit !== undefined) body.limit = params.limit;
|
||||
body.userIds = params.userIds;
|
||||
|
||||
const res = await fetch(
|
||||
`https://oapi.dingtalk.com/attendance/listRecord?access_token=${token}`,
|
||||
@@ -470,7 +475,9 @@ export class DingTalkService {
|
||||
};
|
||||
if (data.errcode !== 0) throw new Error(`钉钉考勤获取失败: ${data.errmsg}`);
|
||||
|
||||
return (data.recordresult ?? []).map((r) => ({
|
||||
const records = data.recordresult ?? [];
|
||||
|
||||
return records.map((r) => ({
|
||||
userId: r.userId,
|
||||
userName: '',
|
||||
workDate: new Date(r.workDate).toISOString().slice(0, 10),
|
||||
@@ -538,28 +545,50 @@ export class DingTalkService {
|
||||
return data.result!.id;
|
||||
}
|
||||
|
||||
/** 查询所有班次摘要 */
|
||||
/** 查询所有班次摘要(每页最多200条) */
|
||||
async queryShifts(opUserId = 'manager'): Promise<DingTalkShiftSummary[]> {
|
||||
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置');
|
||||
const token = await this.getAccessToken();
|
||||
|
||||
await this.rateLimit();
|
||||
const res = await fetch(
|
||||
`https://oapi.dingtalk.com/topapi/attendance/shift/list?access_token=${token}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ op_user_id: opUserId }),
|
||||
},
|
||||
);
|
||||
const data = (await res.json()) as {
|
||||
errcode: number; errmsg: string;
|
||||
result?: Array<{ id: number; name: string }>;
|
||||
};
|
||||
if (data.errcode !== 0) {
|
||||
throw new Error(`钉钉查询班次失败: ${data.errmsg} (code=${data.errcode})`);
|
||||
const all: DingTalkShiftSummary[] = [];
|
||||
let cursor = 0;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
await this.rateLimit();
|
||||
const res = await fetch(
|
||||
`https://oapi.dingtalk.com/topapi/attendance/shift/list?access_token=${token}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ op_user_id: opUserId, cursor }),
|
||||
},
|
||||
);
|
||||
const data = (await res.json()) as {
|
||||
errcode: number;
|
||||
errmsg: string;
|
||||
result?: {
|
||||
cursor?: number;
|
||||
has_more?: boolean;
|
||||
result?: Array<{ id: number; name: string }>;
|
||||
};
|
||||
};
|
||||
if (data.errcode !== 0) {
|
||||
throw new Error(`钉钉查询班次失败: ${data.errmsg} (code=${data.errcode})`);
|
||||
}
|
||||
|
||||
const page = data.result;
|
||||
all.push(...(page?.result ?? []).map((s) => ({ id: s.id, name: s.name })));
|
||||
hasMore = page?.has_more ?? false;
|
||||
if (hasMore) {
|
||||
if (page?.cursor === undefined || page.cursor === cursor) {
|
||||
throw new Error('钉钉查询班次失败: 分页游标无效');
|
||||
}
|
||||
cursor = page.cursor;
|
||||
}
|
||||
}
|
||||
return (data.result ?? []).map((s) => ({ id: s.id, name: s.name }));
|
||||
|
||||
return all;
|
||||
}
|
||||
|
||||
|
||||
@@ -572,22 +601,7 @@ export class DingTalkService {
|
||||
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置');
|
||||
const token = await this.getAccessToken();
|
||||
|
||||
const topGroup: Record<string, unknown> = {
|
||||
name: params.name,
|
||||
type: params.type,
|
||||
owner: params.owner,
|
||||
members: params.members.map((m) => ({
|
||||
role: m.role,
|
||||
type: m.type,
|
||||
user_id: m.user_id,
|
||||
})),
|
||||
enable_emp_select_class: params.enable_emp_select_class ?? true,
|
||||
disable_check_without_schedule: params.disable_check_without_schedule ?? false,
|
||||
disable_check_when_rest: params.disable_check_when_rest ?? true,
|
||||
};
|
||||
if (params.shift_ids?.length) {
|
||||
topGroup.shift_vo_list = params.shift_ids.map((id) => ({ id }));
|
||||
}
|
||||
const topGroup = this.buildAttendanceGroupBody(params);
|
||||
|
||||
const body = { op_user_id: params.owner, top_group: topGroup };
|
||||
|
||||
@@ -611,6 +625,66 @@ export class DingTalkService {
|
||||
return data.result!.id;
|
||||
}
|
||||
|
||||
/** 更新排班制考勤组,确保复用考勤组时同步最新打卡限制 */
|
||||
async updateAttendanceGroup(params: DingTalkGroupUpdateParams): Promise<void> {
|
||||
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置');
|
||||
const token = await this.getAccessToken();
|
||||
const topGroup = { ...this.buildAttendanceGroupBody(params), id: params.id };
|
||||
|
||||
await this.rateLimit();
|
||||
const res = await fetch(
|
||||
`https://oapi.dingtalk.com/topapi/attendance/group/modify?access_token=${token}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ op_user_id: params.owner, top_group: topGroup }),
|
||||
},
|
||||
);
|
||||
const data = (await res.json()) as {
|
||||
errcode?: number;
|
||||
errmsg?: string;
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
};
|
||||
const succeeded = data.success === true || data.errcode === 0;
|
||||
if (!succeeded) {
|
||||
throw new Error(
|
||||
`钉钉更新考勤组失败: ${data.message || data.errmsg || '未知错误'} ` +
|
||||
`(code=${data.errcode ?? 'unknown'})`,
|
||||
);
|
||||
}
|
||||
this.logger.log(`钉钉考勤组更新成功: ${params.name} (id=${params.id})`);
|
||||
}
|
||||
|
||||
private buildAttendanceGroupBody(params: DingTalkGroupParams): Record<string, unknown> {
|
||||
const machineOnly = params.attendance_machine_only ?? false;
|
||||
const topGroup: Record<string, unknown> = {
|
||||
name: params.name,
|
||||
type: params.type,
|
||||
owner: params.owner,
|
||||
members: params.members.map((m) => ({
|
||||
role: m.role,
|
||||
type: m.type,
|
||||
user_id: m.user_id,
|
||||
})),
|
||||
enable_emp_select_class: machineOnly ? false : (params.enable_emp_select_class ?? true),
|
||||
disable_check_without_schedule: machineOnly ? true : (params.disable_check_without_schedule ?? false),
|
||||
disable_check_when_rest: params.disable_check_when_rest ?? true,
|
||||
};
|
||||
if (params.shift_ids?.length) {
|
||||
topGroup.shift_vo_list = params.shift_ids.map((id) => ({ id }));
|
||||
}
|
||||
if (machineOnly) {
|
||||
Object.assign(topGroup, {
|
||||
enable_outside_check: false,
|
||||
enable_position_ble: false,
|
||||
positions: [],
|
||||
wifis: [],
|
||||
});
|
||||
}
|
||||
return topGroup;
|
||||
}
|
||||
|
||||
/** 查询所有考勤组摘要(分页,每页10条) */
|
||||
async queryAttendanceGroups(opUserId = 'manager'): Promise<DingTalkGroupSummary[]> {
|
||||
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置');
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Observable, map } from 'rxjs';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import { NotificationQueryDto } from './dto/notification.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
|
||||
interface AuthenticatedUser {
|
||||
id: number;
|
||||
@@ -25,6 +26,7 @@ interface AuthenticatedRequest extends Request {
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@RequirePermission('notification:view')
|
||||
@Controller('notifications')
|
||||
export class NotificationsController {
|
||||
constructor(private readonly service: NotificationsService) {}
|
||||
|
||||
23
apps/server/src/rbac/rbac.permissions.spec.ts
Normal file
23
apps/server/src/rbac/rbac.permissions.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { PRESET_ROLES } from './rbac.service';
|
||||
|
||||
describe('preset role permissions', () => {
|
||||
it('gives teachers explicit workspace permissions without class/schedule delete privileges', () => {
|
||||
const teacher = PRESET_ROLES.find((role) => role.code === 'teacher');
|
||||
|
||||
expect(teacher).toBeDefined();
|
||||
expect(teacher?.permissionGroups).toEqual(['notification', 'profile']);
|
||||
expect(teacher?.extraPermissions).toEqual(
|
||||
expect.arrayContaining([
|
||||
'student:view',
|
||||
'class:view',
|
||||
'schedule:view',
|
||||
'attendance:view',
|
||||
'attendance:create',
|
||||
'attendance:export',
|
||||
]),
|
||||
);
|
||||
expect(teacher?.extraPermissions).not.toEqual(
|
||||
expect.arrayContaining(['class:delete', 'schedule:delete']),
|
||||
);
|
||||
});
|
||||
});
|
||||
57
apps/server/src/rbac/rbac.seed.spec.ts
Normal file
57
apps/server/src/rbac/rbac.seed.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { RbacService } from './rbac.service';
|
||||
|
||||
describe('RbacService seedData', () => {
|
||||
it('adds preset permissions to system roles without removing manually granted permissions', async () => {
|
||||
const permissions = [
|
||||
{ id: 1, code: 'profile:view', name: '查看个人资料', group: 'profile' },
|
||||
{ id: 2, code: 'notification:view', name: '查看通知', group: 'notification' },
|
||||
{ id: 3, code: 'student:view', name: '查看学生', group: 'student' },
|
||||
{ id: 4, code: 'class:view', name: '查看班级', group: 'class' },
|
||||
{ id: 5, code: 'schedule:view', name: '查看排课', group: 'schedule' },
|
||||
{ id: 6, code: 'attendance:view', name: '查看考勤', group: 'attendance' },
|
||||
{ id: 7, code: 'attendance:create', name: '新增考勤', group: 'attendance' },
|
||||
{ id: 8, code: 'attendance:export', name: '导出考勤', group: 'attendance' },
|
||||
{ id: 9, code: 'room:view', name: '查看宿舍', group: 'room' },
|
||||
];
|
||||
const teacherRole = {
|
||||
id: 1,
|
||||
name: '老师',
|
||||
description: '查看和管理本班学生',
|
||||
isSystem: true,
|
||||
permissions: [permissions[8]],
|
||||
};
|
||||
|
||||
const permRepo = {
|
||||
findOne: jest.fn(
|
||||
async ({ where }: any) => permissions.find((p) => p.code === where.code) ?? null,
|
||||
),
|
||||
create: jest.fn((value) => value),
|
||||
save: jest.fn(async (value) => value),
|
||||
find: jest.fn(async () => permissions),
|
||||
};
|
||||
const roleRepo = {
|
||||
findOne: jest.fn(async ({ where }: any) => (where.name === '老师' ? teacherRole : null)),
|
||||
create: jest.fn((value) => ({ ...value, permissions: [] })),
|
||||
save: jest.fn(async (value) => value),
|
||||
find: jest.fn(async () => [teacherRole]),
|
||||
};
|
||||
const userRepo = { count: jest.fn(async () => 1), create: jest.fn(), save: jest.fn() };
|
||||
|
||||
const service = new RbacService(
|
||||
permRepo as never,
|
||||
roleRepo as never,
|
||||
userRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
await service.seedData();
|
||||
|
||||
expect(teacherRole.permissions.map((permission) => permission.code)).toEqual(
|
||||
expect.arrayContaining(['room:view', 'profile:view', 'student:view', 'attendance:create']),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,21 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, In } from 'typeorm';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { Permission, Role, User, Class, ClassStudent, ClassTeacher, ClassSchedule, Student } from '../entities';
|
||||
import {
|
||||
Permission,
|
||||
Role,
|
||||
User,
|
||||
Class,
|
||||
ClassStudent,
|
||||
ClassTeacher,
|
||||
ClassSchedule,
|
||||
Student,
|
||||
} from '../entities';
|
||||
|
||||
const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> = [
|
||||
{ code: 'dashboard:view', name: '查看数据面板', group: 'dashboard' },
|
||||
{ code: 'profile:view', name: '查看个人资料', group: 'profile' },
|
||||
{ code: 'notification:view', name: '查看通知', group: 'notification' },
|
||||
{ code: 'student:view', name: '查看学生', group: 'student' },
|
||||
{ code: 'student:create', name: '新增学生', group: 'student' },
|
||||
{ code: 'student:edit', name: '编辑学生', group: 'student' },
|
||||
@@ -87,7 +98,7 @@ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> =
|
||||
{ code: 'department:delete', name: '删除部门', group: 'department' },
|
||||
];
|
||||
|
||||
const PRESET_ROLES: Array<{
|
||||
export const PRESET_ROLES: Array<{
|
||||
name: string;
|
||||
code: string;
|
||||
description: string;
|
||||
@@ -119,6 +130,8 @@ const PRESET_ROLES: Array<{
|
||||
'class',
|
||||
'schedule',
|
||||
'attendance',
|
||||
'notification',
|
||||
'profile',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -126,36 +139,61 @@ const PRESET_ROLES: Array<{
|
||||
code: 'teacher',
|
||||
description: '查看和管理本班学生',
|
||||
isSystem: true,
|
||||
permissionGroups: ['student'],
|
||||
extraPermissions: ['student:view'],
|
||||
permissionGroups: ['notification', 'profile'],
|
||||
extraPermissions: [
|
||||
'student:view',
|
||||
'class:view',
|
||||
'schedule:view',
|
||||
'attendance:view',
|
||||
'attendance:create',
|
||||
'attendance:export',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '机构负责人',
|
||||
code: 'institution_head',
|
||||
description: '管理机构教室和课程',
|
||||
isSystem: true,
|
||||
permissionGroups: ['classroom', 'rental', 'tenant'],
|
||||
permissionGroups: ['classroom', 'rental', 'tenant', 'notification', 'profile'],
|
||||
},
|
||||
{
|
||||
name: '财务',
|
||||
code: 'finance',
|
||||
description: '管理费用、账单与押金',
|
||||
isSystem: true,
|
||||
permissionGroups: ['expense', 'bill', 'deposit', 'dashboard'],
|
||||
permissionGroups: ['expense', 'bill', 'deposit', 'dashboard', 'notification', 'profile'],
|
||||
},
|
||||
{
|
||||
name: '宿管',
|
||||
code: 'dorm_manager',
|
||||
description: '管理宿舍入住与宿舍信息',
|
||||
isSystem: true,
|
||||
permissionGroups: ['student', 'room', 'occupancy', 'deposit', 'dashboard'],
|
||||
permissionGroups: [
|
||||
'student',
|
||||
'room',
|
||||
'occupancy',
|
||||
'deposit',
|
||||
'dashboard',
|
||||
'notification',
|
||||
'profile',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '教务',
|
||||
code: 'academic',
|
||||
description: '管理班级、排课、考勤、学习与考试',
|
||||
isSystem: true,
|
||||
permissionGroups: ['class', 'schedule', 'attendance', 'classroom', 'learning', 'exam', 'dashboard'],
|
||||
permissionGroups: [
|
||||
'class',
|
||||
'schedule',
|
||||
'attendance',
|
||||
'classroom',
|
||||
'learning',
|
||||
'exam',
|
||||
'dashboard',
|
||||
'notification',
|
||||
'profile',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -189,7 +227,12 @@ export class RbacService {
|
||||
const exists = await this.roleRepo.findOne({ where: { name: r.name } });
|
||||
if (!exists) {
|
||||
await this.roleRepo.save(
|
||||
this.roleRepo.create({ name: r.name, description: r.description, isSystem: r.isSystem }),
|
||||
this.roleRepo.create({
|
||||
name: r.name,
|
||||
code: r.code,
|
||||
description: r.description,
|
||||
isSystem: r.isSystem,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -197,8 +240,12 @@ export class RbacService {
|
||||
|
||||
// Step 3: 构建角色-权限关联
|
||||
for (const preset of PRESET_ROLES) {
|
||||
const role = allRoles.find((r) => r.name === preset.name);
|
||||
const role = allRoles.find((r) => r.name === preset.name || r.code === preset.code);
|
||||
if (!role) continue;
|
||||
if (role.code !== preset.code) {
|
||||
role.code = preset.code;
|
||||
await this.roleRepo.save(role);
|
||||
}
|
||||
|
||||
let perms: Permission[];
|
||||
if (preset.permissionGroups.length === 0) {
|
||||
@@ -215,11 +262,12 @@ export class RbacService {
|
||||
);
|
||||
}
|
||||
|
||||
// 幂等:只插入尚未关联的
|
||||
const existingIds = new Set(role.permissions.map((p) => p.id));
|
||||
const toAdd = perms.filter((p) => !existingIds.has(p.id));
|
||||
if (toAdd.length > 0) {
|
||||
role.permissions = [...role.permissions, ...toAdd];
|
||||
// 系统角色只补齐预置权限,不移除管理员手动授予的额外权限。
|
||||
// 这样新增权限(例如 profile:view)会自动补上,同时避免重启后覆盖人工配置。
|
||||
const currentIds = new Set(role.permissions.map((permission) => permission.id));
|
||||
const missingPerms = perms.filter((permission) => !currentIds.has(permission.id));
|
||||
if (missingPerms.length > 0) {
|
||||
role.permissions = [...role.permissions, ...missingPerms];
|
||||
await this.roleRepo.save(role);
|
||||
}
|
||||
}
|
||||
@@ -247,7 +295,6 @@ export class RbacService {
|
||||
this.logger.log(`种子数据初始化完成: ${allPerms.length} 权限点, ${allRoles.length} 角色`);
|
||||
}
|
||||
|
||||
|
||||
async findAllRoles(): Promise<Role[]> {
|
||||
return this.roleRepo.find({
|
||||
relations: ['permissions'],
|
||||
@@ -450,14 +497,18 @@ export class RbacService {
|
||||
};
|
||||
}
|
||||
|
||||
async updateUserProfile(id: number, dto: { subjects?: string[]; joinedAt?: string; qualifications?: string }) {
|
||||
async updateUserProfile(
|
||||
id: number,
|
||||
dto: { subjects?: string[]; joinedAt?: string; qualifications?: string },
|
||||
) {
|
||||
const user = await this.userRepo.findOne({ where: { id } });
|
||||
if (!user) throw new Error('用户不存在');
|
||||
const current = user.profile || {};
|
||||
user.profile = {
|
||||
subjects: dto.subjects !== undefined ? dto.subjects : current.subjects,
|
||||
joinedAt: dto.joinedAt !== undefined ? dto.joinedAt : current.joinedAt,
|
||||
qualifications: dto.qualifications !== undefined ? dto.qualifications : current.qualifications,
|
||||
qualifications:
|
||||
dto.qualifications !== undefined ? dto.qualifications : current.qualifications,
|
||||
};
|
||||
await this.userRepo.save(user);
|
||||
return { message: '资料已更新', profile: user.profile };
|
||||
@@ -501,6 +552,7 @@ export class RbacService {
|
||||
.andWhere('cs.startDate <= :today', { today: todayStr })
|
||||
.andWhere('cs.endDate >= :today', { today: todayStr })
|
||||
.andWhere('cs.status = :status', { status: 'active' })
|
||||
.andWhere('cs.teacherId = :userId', { userId })
|
||||
.orderBy('cs.startTime', 'ASC')
|
||||
.getMany();
|
||||
|
||||
@@ -524,6 +576,8 @@ export class RbacService {
|
||||
todaySchedules: todaySchedules.map((s) => ({
|
||||
id: s.id,
|
||||
classId: s.classId,
|
||||
classroomId: s.classroomId,
|
||||
teacherId: s.teacherId,
|
||||
weekDay: s.weekDay,
|
||||
startTime: s.startTime,
|
||||
endTime: s.endTime,
|
||||
@@ -535,18 +589,18 @@ export class RbacService {
|
||||
}
|
||||
|
||||
async getTeachers(query?: { search?: string; page?: number; pageSize?: number }) {
|
||||
const page = query?.page || 1;
|
||||
const pageSize = query?.pageSize || 20;
|
||||
const teacherRoleCodes = ['teacher', 'class_teacher', 'dormitory_supervisor', 'super_admin'];
|
||||
const teacherRoleNames = ['老师', '班主任', '宿管老师', '超管'];
|
||||
|
||||
const qb = this.userRepo
|
||||
.createQueryBuilder('u')
|
||||
.leftJoin('u.roles', 'role')
|
||||
.leftJoin(ClassTeacher, 'ct', 'ct.userId = u.id')
|
||||
.leftJoin('ct.class', 'c')
|
||||
.select([
|
||||
'u.id', 'u.username', 'u.name', 'u.isActive', 'u.profile', 'u.lastLoginAt',
|
||||
'role.code', 'role.name',
|
||||
'ct.roleType', 'ct.subject', 'ct.id',
|
||||
'c.id', 'c.name',
|
||||
])
|
||||
.where('role.code IN (:...roles)', { roles: ['teacher', 'class_teacher', 'dormitory_supervisor', 'super_admin'] });
|
||||
.leftJoinAndSelect('u.roles', 'role')
|
||||
.where('(role.code IN (:...roleCodes) OR role.name IN (:...roleNames))', {
|
||||
roleCodes: teacherRoleCodes,
|
||||
roleNames: teacherRoleNames,
|
||||
});
|
||||
|
||||
if (query?.search) {
|
||||
qb.andWhere('(u.name LIKE :s OR u.username LIKE :s)', { s: `%${query.search}%` });
|
||||
@@ -555,37 +609,38 @@ export class RbacService {
|
||||
const total = await qb.getCount();
|
||||
const users = await qb
|
||||
.orderBy('u.name', 'ASC')
|
||||
.skip(((query?.page || 1) - 1) * (query?.pageSize || 20))
|
||||
.take(query?.pageSize || 20)
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize)
|
||||
.getMany();
|
||||
|
||||
const list = users.map((u) => {
|
||||
// TypeORM injects __ct__ and __class__ via leftJoin on non-entity relations
|
||||
const raw = u as unknown as Record<string, unknown>;
|
||||
const classAssignments: Array<{ roleType?: string; subject?: string; className: string | null }> = [];
|
||||
const rawCt = raw['__ct__'];
|
||||
if (Array.isArray(rawCt)) {
|
||||
for (const ct of rawCt) {
|
||||
const ctRaw = ct as Record<string, unknown>;
|
||||
const cls = ctRaw['__class__'] as Record<string, unknown> | undefined;
|
||||
classAssignments.push({
|
||||
roleType: typeof ctRaw['roleType'] === 'string' ? ctRaw['roleType'] : undefined,
|
||||
subject: typeof ctRaw['subject'] === 'string' ? ctRaw['subject'] : undefined,
|
||||
className: cls && typeof cls['name'] === 'string' ? cls['name'] : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
name: u.name,
|
||||
isActive: u.isActive,
|
||||
profile: u.profile,
|
||||
lastLoginAt: u.lastLoginAt,
|
||||
roles: u.roles || [],
|
||||
classAssignments,
|
||||
};
|
||||
});
|
||||
const userIds = users.map((user) => user.id);
|
||||
const assignments =
|
||||
userIds.length > 0
|
||||
? await this.classTeacherRepo.find({ where: { userId: In(userIds) }, relations: ['class'] })
|
||||
: [];
|
||||
const assignmentsByUser = new Map<number, ClassTeacher[]>();
|
||||
for (const assignment of assignments) {
|
||||
const list = assignmentsByUser.get(assignment.userId) || [];
|
||||
list.push(assignment);
|
||||
assignmentsByUser.set(assignment.userId, list);
|
||||
}
|
||||
|
||||
const list = users.map((u) => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
name: u.name,
|
||||
isActive: u.isActive,
|
||||
profile: u.profile,
|
||||
lastLoginAt: u.lastLoginAt,
|
||||
roles: u.roles || [],
|
||||
classAssignments: (assignmentsByUser.get(u.id) || []).map((assignment) => ({
|
||||
id: assignment.id,
|
||||
classId: assignment.classId,
|
||||
roleType: assignment.roleType,
|
||||
subject: assignment.subject,
|
||||
className: assignment.class?.name || null,
|
||||
})),
|
||||
}));
|
||||
|
||||
return { list, total };
|
||||
}
|
||||
|
||||
@@ -25,6 +25,13 @@ import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationType } from '../entities/notification.entity';
|
||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
|
||||
interface RequestUser {
|
||||
id: number;
|
||||
username: string;
|
||||
permissions?: string[];
|
||||
isSuperAdmin?: boolean;
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('class-schedules')
|
||||
export class SchedulesController {
|
||||
@@ -34,24 +41,48 @@ export class SchedulesController {
|
||||
private readonly notificationsService: NotificationsService,
|
||||
) {}
|
||||
|
||||
private canManageAllSchedules(user: RequestUser): boolean {
|
||||
return (
|
||||
user.isSuperAdmin === true ||
|
||||
user.permissions?.includes('schedule:edit') === true ||
|
||||
user.permissions?.includes('class:edit') === true
|
||||
);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('schedule:view')
|
||||
findAll(@Query() query: QueryScheduleDto) {
|
||||
return this.service.findAll(query);
|
||||
async findAll(@Query() query: QueryScheduleDto, @Request() req: { user: RequestUser }) {
|
||||
const classIds = await this.service.getAccessibleClassIds(
|
||||
req.user.id,
|
||||
this.canManageAllSchedules(req.user),
|
||||
);
|
||||
return this.service.findAll(query, classIds);
|
||||
}
|
||||
|
||||
@Get('weekly')
|
||||
@RequirePermission('schedule:view')
|
||||
getWeeklyView(@Query() query: WeeklyViewQueryDto) {
|
||||
return this.service.getWeeklyView(query);
|
||||
async getWeeklyView(@Query() query: WeeklyViewQueryDto, @Request() req: { user: RequestUser }) {
|
||||
const classIds = await this.service.getAccessibleClassIds(
|
||||
req.user.id,
|
||||
this.canManageAllSchedules(req.user),
|
||||
);
|
||||
return this.service.getWeeklyView(query, classIds);
|
||||
}
|
||||
|
||||
@Get('classes/:classId/teachers')
|
||||
@RequirePermission('schedule:view')
|
||||
async getClassTeachers(@Param('classId') classId: string, @Request() req: { user: RequestUser }) {
|
||||
const classIds = await this.service.getAccessibleClassIds(
|
||||
req.user.id,
|
||||
this.canManageAllSchedules(req.user),
|
||||
);
|
||||
if (classIds && !classIds.includes(+classId)) return [];
|
||||
return this.service.getClassTeachers(+classId);
|
||||
}
|
||||
|
||||
@Get('classroom/:id/occupancy')
|
||||
@RequirePermission('schedule:view')
|
||||
getClassroomOccupancy(
|
||||
@Param('id') id: string,
|
||||
@Query('date') date?: string,
|
||||
) {
|
||||
getClassroomOccupancy(@Param('id') id: string, @Query('date') date?: string) {
|
||||
return this.service.getClassroomOccupancy(+id, date);
|
||||
}
|
||||
|
||||
@@ -63,7 +94,10 @@ export class SchedulesController {
|
||||
|
||||
@Post()
|
||||
@RequirePermission('schedule:create')
|
||||
async create(@Body() dto: CreateScheduleDto, @Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> }) {
|
||||
async create(
|
||||
@Body() dto: CreateScheduleDto,
|
||||
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
try {
|
||||
const result = await this.service.create(dto);
|
||||
@@ -83,9 +117,16 @@ export class SchedulesController {
|
||||
if (error instanceof ConflictException) {
|
||||
try {
|
||||
const conflicts = await this.service.checkConflict(
|
||||
dto.classroomId, dto.weekDay, dto.startTime, dto.endTime, dto.startDate, dto.endDate,
|
||||
dto.classroomId,
|
||||
dto.weekDay,
|
||||
dto.startTime,
|
||||
dto.endTime,
|
||||
dto.startDate,
|
||||
dto.endDate,
|
||||
);
|
||||
const teacherIds = [...new Set(conflicts.map(c => c.teacherId).filter((id): id is number => id != null))];
|
||||
const teacherIds = [
|
||||
...new Set(conflicts.map((c) => c.teacherId).filter((id): id is number => id != null)),
|
||||
];
|
||||
if (teacherIds.length > 0) {
|
||||
void this.notificationsService.create({
|
||||
recipientIds: teacherIds,
|
||||
@@ -127,9 +168,16 @@ export class SchedulesController {
|
||||
if (error instanceof ConflictException) {
|
||||
try {
|
||||
const conflicts = await this.service.checkConflict(
|
||||
existing.classroomId, existing.weekDay, existing.startTime, existing.endTime, existing.startDate, existing.endDate,
|
||||
existing.classroomId,
|
||||
existing.weekDay,
|
||||
existing.startTime,
|
||||
existing.endTime,
|
||||
existing.startDate,
|
||||
existing.endDate,
|
||||
);
|
||||
const teacherIds = [...new Set(conflicts.map(c => c.teacherId).filter((id): id is number => id != null))];
|
||||
const teacherIds = [
|
||||
...new Set(conflicts.map((c) => c.teacherId).filter((id): id is number => id != null)),
|
||||
];
|
||||
if (teacherIds.length > 0) {
|
||||
void this.notificationsService.create({
|
||||
recipientIds: teacherIds,
|
||||
@@ -146,7 +194,10 @@ export class SchedulesController {
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('schedule:delete')
|
||||
async remove(@Param('id') id: string, @Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> }) {
|
||||
async remove(
|
||||
@Param('id') id: string,
|
||||
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.remove(+id);
|
||||
await this.logService.log({
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ClassSchedule, Class, ClassroomRental } from '../entities';
|
||||
import { ClassSchedule, Class, ClassroomRental, ClassTeacher } from '../entities';
|
||||
import { SchedulesService } from './schedules.service';
|
||||
import { SchedulesController } from './schedules.controller';
|
||||
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([ClassSchedule, Class, ClassroomRental]), OperationLogsModule, NotificationsModule],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ClassSchedule, Class, ClassroomRental, ClassTeacher]),
|
||||
OperationLogsModule,
|
||||
NotificationsModule,
|
||||
],
|
||||
controllers: [SchedulesController],
|
||||
providers: [SchedulesService],
|
||||
exports: [SchedulesService],
|
||||
|
||||
41
apps/server/src/schedules/schedules.scope.spec.ts
Normal file
41
apps/server/src/schedules/schedules.scope.spec.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { SchedulesService } from './schedules.service';
|
||||
|
||||
const createQb = () => ({
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
addOrderBy: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn().mockResolvedValue([]),
|
||||
});
|
||||
|
||||
describe('SchedulesService — teacher class scope', () => {
|
||||
it('filters schedule list to assigned classes when no class filter is selected', async () => {
|
||||
const qb = createQb();
|
||||
const scheduleRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };
|
||||
const service = new SchedulesService(
|
||||
scheduleRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
await service.findAll({}, [3, 5]);
|
||||
|
||||
expect(qb.andWhere).toHaveBeenCalledWith('cs.classId IN (:...accessibleClassIds)', {
|
||||
accessibleClassIds: [3, 5],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns no schedules when teacher has no assigned classes', async () => {
|
||||
const qb = createQb();
|
||||
const scheduleRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };
|
||||
const service = new SchedulesService(
|
||||
scheduleRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
await expect(service.findAll({}, [])).resolves.toEqual([]);
|
||||
expect(qb.getMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { SchedulesService } from './schedules.service';
|
||||
import { ClassSchedule, ScheduleType } from '../entities/class-schedule.entity';
|
||||
import { ClassroomRental } from '../entities/classroom-rental.entity';
|
||||
import { Class } from '../entities/class.entity';
|
||||
import { ClassTeacher } from '../entities/class-teacher.entity';
|
||||
|
||||
/** Build a mock query-builder where each chain method returns `this`. */
|
||||
function mockQueryBuilder<T>(results: T[] = []) {
|
||||
@@ -34,7 +35,14 @@ describe('SchedulesService — checkConflict', () => {
|
||||
SchedulesService,
|
||||
{ provide: getRepositoryToken(ClassSchedule), useValue: mockRepo },
|
||||
{ provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } },
|
||||
{ provide: getRepositoryToken(ClassroomRental), useValue: { createQueryBuilder: jest.fn() } },
|
||||
{
|
||||
provide: getRepositoryToken(ClassroomRental),
|
||||
useValue: { createQueryBuilder: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(ClassTeacher),
|
||||
useValue: { find: jest.fn().mockResolvedValue([]) },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -103,7 +111,12 @@ describe('SchedulesService — checkConflict', () => {
|
||||
it('overlapping classroom rental → ConflictException', async () => {
|
||||
const qb = mockQueryBuilder<ClassSchedule>([]);
|
||||
const rentalQb = mockQueryBuilder<ClassroomRental>([
|
||||
{ id: 10, startDate: '2026-03-01', endDate: '2026-03-31', status: 'active' } as ClassroomRental,
|
||||
{
|
||||
id: 10,
|
||||
startDate: '2026-03-01',
|
||||
endDate: '2026-03-31',
|
||||
status: 'active',
|
||||
} as ClassroomRental,
|
||||
]);
|
||||
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb);
|
||||
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb);
|
||||
@@ -135,7 +148,14 @@ describe('SchedulesService — getClassroomOccupancy', () => {
|
||||
SchedulesService,
|
||||
{ provide: getRepositoryToken(ClassSchedule), useValue: { createQueryBuilder: jest.fn() } },
|
||||
{ provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } },
|
||||
{ provide: getRepositoryToken(ClassroomRental), useValue: { createQueryBuilder: jest.fn() } },
|
||||
{
|
||||
provide: getRepositoryToken(ClassroomRental),
|
||||
useValue: { createQueryBuilder: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(ClassTeacher),
|
||||
useValue: { find: jest.fn().mockResolvedValue([]) },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ClassSchedule, Class, ClassroomRental } from '../entities';
|
||||
import { ClassSchedule, Class, ClassroomRental, ClassTeacher } from '../entities';
|
||||
|
||||
import {
|
||||
CreateScheduleDto,
|
||||
@@ -18,23 +23,75 @@ export class SchedulesService {
|
||||
@InjectRepository(Class) private readonly classRepo: Repository<Class>,
|
||||
@InjectRepository(ClassroomRental)
|
||||
private readonly rentalRepo: Repository<ClassroomRental>,
|
||||
@InjectRepository(ClassTeacher)
|
||||
private readonly classTeacherRepo: Repository<ClassTeacher>,
|
||||
) {}
|
||||
|
||||
async findAll(query: QueryScheduleDto) {
|
||||
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 findAll(query: QueryScheduleDto, accessibleClassIds?: number[]) {
|
||||
const qb = this.scheduleRepo.createQueryBuilder('cs');
|
||||
|
||||
if (query.classroomId) qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId });
|
||||
if (query.classroomId)
|
||||
qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId });
|
||||
if (query.classId) qb.andWhere('cs.classId = :classId', { classId: query.classId });
|
||||
else if (accessibleClassIds) {
|
||||
if (accessibleClassIds.length === 0) return [];
|
||||
qb.andWhere('cs.classId IN (:...accessibleClassIds)', { accessibleClassIds });
|
||||
}
|
||||
if (query.weekDay) qb.andWhere('cs.weekDay = :weekDay', { weekDay: query.weekDay });
|
||||
if (query.startDate) qb.andWhere('cs.startDate >= :startDate', { startDate: query.startDate });
|
||||
if (query.endDate) qb.andWhere('cs.endDate <= :endDate', { endDate: query.endDate });
|
||||
|
||||
qb.orderBy('cs.weekDay', 'ASC')
|
||||
.addOrderBy('cs.startTime', 'ASC');
|
||||
qb.orderBy('cs.weekDay', 'ASC').addOrderBy('cs.startTime', 'ASC');
|
||||
|
||||
return qb.getMany();
|
||||
}
|
||||
|
||||
async getClassTeachers(classId: number) {
|
||||
const teachers = await this.classTeacherRepo.find({
|
||||
where: { classId },
|
||||
relations: ['user'],
|
||||
order: { roleType: 'ASC', subject: 'ASC' },
|
||||
});
|
||||
return teachers.map((teacher) => ({
|
||||
id: teacher.id,
|
||||
userId: teacher.userId,
|
||||
username: teacher.user?.username,
|
||||
name: teacher.user?.name,
|
||||
roleType: teacher.roleType,
|
||||
subject: teacher.subject,
|
||||
}));
|
||||
}
|
||||
|
||||
private async normalizeTeacherForSchedule<
|
||||
T extends { classId?: number; subject?: string; teacherId?: number | null },
|
||||
>(dto: T): Promise<T> {
|
||||
if (!dto.classId || !dto.subject || dto.teacherId) return dto;
|
||||
const teachers = await this.classTeacherRepo.find({
|
||||
where: { classId: dto.classId, roleType: 'subject_teacher', subject: dto.subject },
|
||||
});
|
||||
if (teachers.length === 1) {
|
||||
dto.teacherId = teachers[0].userId;
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
|
||||
private async assertTeacherAssignedToClass(
|
||||
classId: number | null | undefined,
|
||||
teacherId: number | null | undefined,
|
||||
) {
|
||||
if (!classId || !teacherId) return;
|
||||
const assignment = await this.classTeacherRepo.findOne({
|
||||
where: { classId, userId: teacherId },
|
||||
});
|
||||
if (!assignment) throw new BadRequestException('只能选择该班级已配置的教师');
|
||||
}
|
||||
|
||||
async findOne(id: number) {
|
||||
const schedule = await this.scheduleRepo.findOne({ where: { id } });
|
||||
if (!schedule) throw new NotFoundException('排课记录不存在');
|
||||
@@ -42,7 +99,16 @@ export class SchedulesService {
|
||||
}
|
||||
|
||||
async create(dto: CreateScheduleDto) {
|
||||
await this.checkConflict(dto.classroomId, dto.weekDay, dto.startTime, dto.endTime, dto.startDate, dto.endDate);
|
||||
await this.normalizeTeacherForSchedule(dto);
|
||||
await this.assertTeacherAssignedToClass(dto.classId, dto.teacherId);
|
||||
await this.checkConflict(
|
||||
dto.classroomId,
|
||||
dto.weekDay,
|
||||
dto.startTime,
|
||||
dto.endTime,
|
||||
dto.startDate,
|
||||
dto.endDate,
|
||||
);
|
||||
|
||||
const schedule = this.scheduleRepo.create(dto);
|
||||
const saved = await this.scheduleRepo.save(schedule);
|
||||
@@ -61,6 +127,16 @@ export class SchedulesService {
|
||||
const startDate = dto.startDate ?? existing.startDate;
|
||||
const endDate = dto.endDate ?? existing.endDate;
|
||||
|
||||
const normalized = await this.normalizeTeacherForSchedule({
|
||||
...dto,
|
||||
classId: dto.classId ?? existing.classId ?? undefined,
|
||||
subject: dto.subject ?? existing.subject,
|
||||
});
|
||||
if (dto.teacherId === undefined && normalized.teacherId !== undefined) {
|
||||
dto.teacherId = normalized.teacherId;
|
||||
}
|
||||
const teacherId = dto.teacherId ?? existing.teacherId;
|
||||
await this.assertTeacherAssignedToClass(dto.classId ?? existing.classId, teacherId);
|
||||
await this.checkConflict(classroomId, weekDay, startTime, endTime, startDate, endDate, id);
|
||||
|
||||
await this.scheduleRepo.update(id, dto);
|
||||
@@ -120,11 +196,15 @@ export class SchedulesService {
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
async getWeeklyView(query: WeeklyViewQueryDto) {
|
||||
async getWeeklyView(query: WeeklyViewQueryDto, accessibleClassIds?: number[]) {
|
||||
const qb = this.scheduleRepo.createQueryBuilder('cs');
|
||||
if (query.classroomId) {
|
||||
qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId });
|
||||
}
|
||||
if (accessibleClassIds) {
|
||||
if (accessibleClassIds.length === 0) return {};
|
||||
qb.andWhere('cs.classId IN (:...accessibleClassIds)', { accessibleClassIds });
|
||||
}
|
||||
if (query.startDate) {
|
||||
qb.andWhere('cs.endDate >= :startDate', { startDate: query.startDate });
|
||||
}
|
||||
@@ -154,16 +234,14 @@ export class SchedulesService {
|
||||
.createQueryBuilder('cs')
|
||||
.where('cs.classroomId = :classroomId', { classroomId })
|
||||
.andWhere('cs.status = :status', { status: 'active' })
|
||||
.andWhere('cs.scheduleType IN (:...scheduleTypes)', { scheduleTypes: ['INTERNAL', 'RENTAL'] });
|
||||
.andWhere('cs.scheduleType IN (:...scheduleTypes)', {
|
||||
scheduleTypes: ['INTERNAL', 'RENTAL'],
|
||||
});
|
||||
|
||||
if (date) {
|
||||
qb.andWhere('cs.startDate <= :date', { date })
|
||||
.andWhere('cs.endDate >= :date', { date });
|
||||
qb.andWhere('cs.startDate <= :date', { date }).andWhere('cs.endDate >= :date', { date });
|
||||
}
|
||||
|
||||
return qb
|
||||
.orderBy('cs.weekDay', 'ASC')
|
||||
.addOrderBy('cs.startTime', 'ASC')
|
||||
.getMany();
|
||||
return qb.orderBy('cs.weekDay', 'ASC').addOrderBy('cs.startTime', 'ASC').getMany();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Tenant } from '../entities/tenant.entity';
|
||||
import { ClassTeacher } from '../entities/class-teacher.entity';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import type { Response } from 'express';
|
||||
import { StudentsService } from './students.service';
|
||||
@@ -30,33 +31,59 @@ import * as ExcelJS from 'exceljs';
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('students')
|
||||
export class StudentsController {
|
||||
|
||||
constructor(
|
||||
private service: StudentsService,
|
||||
private logService: OperationLogsService,
|
||||
@InjectRepository(Tenant) private tenantRepo: Repository<Tenant>,
|
||||
) {}
|
||||
|
||||
private canManageAllStudents(user: { isSuperAdmin?: boolean; permissions?: string[] }): boolean {
|
||||
return (
|
||||
user.isSuperAdmin === true ||
|
||||
user.permissions?.includes('student:edit') === true ||
|
||||
user.permissions?.includes('class:edit') === true
|
||||
);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('student:view')
|
||||
findAll(
|
||||
@Query('name') name?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('includeArchived') includeArchived?: string,
|
||||
@Query('tenantId') tenantId?: string,
|
||||
async findAll(
|
||||
@Query('name') name: string | undefined,
|
||||
@Query('status') status: string | undefined,
|
||||
@Query('includeArchived') includeArchived: string | undefined,
|
||||
@Query('tenantId') tenantId: string | undefined,
|
||||
@Request() req: { user: { id: number; isSuperAdmin?: boolean; permissions?: string[] } },
|
||||
) {
|
||||
return this.service.findAll({
|
||||
name,
|
||||
status,
|
||||
includeArchived: includeArchived === 'true',
|
||||
tenantId: tenantId ? +tenantId : undefined,
|
||||
});
|
||||
const classIds = await this.service.getAccessibleClassIds(
|
||||
req.user.id,
|
||||
this.canManageAllStudents(req.user),
|
||||
);
|
||||
return this.service.findAll(
|
||||
{
|
||||
name,
|
||||
status,
|
||||
includeArchived: includeArchived === 'true',
|
||||
tenantId: tenantId ? +tenantId : undefined,
|
||||
},
|
||||
classIds,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('export')
|
||||
@RequirePermission('student:export')
|
||||
async exportExcel(@Query('includeArchived') includeArchived?: string, @Res() res?: Response, @Request() req?: any) {
|
||||
const students = await this.service.findAll({ includeArchived: includeArchived === 'true' });
|
||||
async exportExcel(
|
||||
@Query('includeArchived') includeArchived?: string,
|
||||
@Res() res?: Response,
|
||||
@Request() req?: any,
|
||||
) {
|
||||
const classIds = await this.service.getAccessibleClassIds(
|
||||
req.user.id,
|
||||
this.canManageAllStudents(req.user),
|
||||
);
|
||||
const students = await this.service.findAll(
|
||||
{ includeArchived: includeArchived === 'true' },
|
||||
classIds,
|
||||
);
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const ws = workbook.addWorksheet('学生名单');
|
||||
ws.columns = [
|
||||
@@ -303,6 +330,60 @@ export class StudentsController {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('import-match')
|
||||
@RequirePermission('student:import')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async matchImport(@UploadedFile() file: Express.Multer.File, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer);
|
||||
const ws = workbook.worksheets[0];
|
||||
const rows: {
|
||||
name: string;
|
||||
phone?: string;
|
||||
idNumber?: string;
|
||||
gender?: string;
|
||||
ethnicity?: string;
|
||||
emergencyContact?: string;
|
||||
emergencyPhone?: string;
|
||||
organization?: string;
|
||||
supervisor?: string;
|
||||
tenantId?: number;
|
||||
}[] = [];
|
||||
ws.eachRow((row, idx) => {
|
||||
if (idx === 1) return;
|
||||
rows.push({
|
||||
name: String(row.getCell(1).value || ''),
|
||||
phone: String(row.getCell(2).value || ''),
|
||||
idNumber: String(row.getCell(3).value || ''),
|
||||
gender: String(row.getCell(4).value || '').trim() || undefined,
|
||||
ethnicity: String(row.getCell(5).value || '').trim() || undefined,
|
||||
emergencyContact: String(row.getCell(6).value || '').trim() || undefined,
|
||||
emergencyPhone: String(row.getCell(7).value || '').trim() || undefined,
|
||||
organization: String(row.getCell(8).value || '').trim() || undefined,
|
||||
supervisor: String(row.getCell(9).value || '').trim() || undefined,
|
||||
});
|
||||
});
|
||||
// Resolve tenant names to IDs
|
||||
for (const row of rows) {
|
||||
if (row.organization) {
|
||||
const tenant = await this.tenantRepo.findOne({ where: { name: row.organization } });
|
||||
if (tenant) row.tenantId = tenant.id;
|
||||
}
|
||||
}
|
||||
const result = await this.service.matchImport(rows);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '学生管理',
|
||||
action: '匹配导入学生',
|
||||
detail: result.message,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get(':id/compare-classes')
|
||||
@RequirePermission('student:view')
|
||||
compareClasses(@Param('id') id: string) {
|
||||
|
||||
@@ -5,11 +5,21 @@ import { Class } from '../entities/class.entity';
|
||||
import { Tenant } from '../entities/tenant.entity';
|
||||
import { ClassStudent } from '../entities/class-student.entity';
|
||||
import { AttendanceRecord } from '../entities/attendance-record.entity';
|
||||
import { ClassTeacher } from '../entities/class-teacher.entity';
|
||||
import { StudentsService } from './students.service';
|
||||
import { StudentsController } from './students.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Student, Class, ClassStudent, AttendanceRecord, Tenant])],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Student,
|
||||
Class,
|
||||
ClassStudent,
|
||||
AttendanceRecord,
|
||||
Tenant,
|
||||
ClassTeacher,
|
||||
]),
|
||||
],
|
||||
controllers: [StudentsController],
|
||||
providers: [StudentsService],
|
||||
exports: [StudentsService],
|
||||
|
||||
43
apps/server/src/students/students.scope.spec.ts
Normal file
43
apps/server/src/students/students.scope.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { StudentsService } from './students.service';
|
||||
|
||||
describe('StudentsService — teacher class scope', () => {
|
||||
it('limits student list to active students in assigned classes', async () => {
|
||||
const repo = { find: jest.fn().mockResolvedValue([{ id: 11, name: '张三' }]) };
|
||||
const classStudentRepo = {
|
||||
find: jest.fn().mockResolvedValue([{ studentId: 11 }, { studentId: 11 }, { studentId: 12 }]),
|
||||
};
|
||||
const service = new StudentsService(
|
||||
repo as never,
|
||||
classStudentRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
await service.findAll({}, [3, 5]);
|
||||
|
||||
expect(classStudentRepo.find).toHaveBeenCalledWith({
|
||||
where: { classId: expect.any(Object), status: 'active' },
|
||||
});
|
||||
expect(repo.find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ id: expect.any(Object) }),
|
||||
relations: ['tenant'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an empty student list when teacher has no assigned classes', async () => {
|
||||
const repo = { find: jest.fn() };
|
||||
const service = new StudentsService(
|
||||
repo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
await expect(service.findAll({}, [])).resolves.toEqual([]);
|
||||
expect(repo.find).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -4,20 +4,35 @@ import { Repository, Like, Not, In, FindOptionsWhere } from 'typeorm';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { Class } from '../entities/class.entity';
|
||||
import { ClassStudent } from '../entities/class-student.entity';
|
||||
import { ClassTeacher } from '../entities/class-teacher.entity';
|
||||
import { AttendanceRecord } from '../entities/attendance-record.entity';
|
||||
import { CreateStudentDto, UpdateStudentDto } from './dto/student.dto';
|
||||
|
||||
@Injectable()
|
||||
export class StudentsService {
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Student) private repo: Repository<Student>,
|
||||
@InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
|
||||
@InjectRepository(Class) private classRepo: Repository<Class>,
|
||||
@InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
|
||||
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
|
||||
) {}
|
||||
|
||||
async findAll(query?: { name?: string; status?: string; includeArchived?: boolean; tenantId?: number | string }) {
|
||||
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 findAll(
|
||||
query?: {
|
||||
name?: string;
|
||||
status?: string;
|
||||
includeArchived?: boolean;
|
||||
tenantId?: number | string;
|
||||
},
|
||||
accessibleClassIds?: number[],
|
||||
) {
|
||||
const where: FindOptionsWhere<Student> = {};
|
||||
if (query?.name) where.name = Like(`%${query.name}%`);
|
||||
if (query?.tenantId) where.tenantId = Number(query.tenantId);
|
||||
@@ -26,6 +41,15 @@ export class StudentsService {
|
||||
} else if (!query?.includeArchived) {
|
||||
where.status = Not(In(['archived', 'staff']));
|
||||
}
|
||||
if (accessibleClassIds) {
|
||||
if (accessibleClassIds.length === 0) return [];
|
||||
const classStudents = await this.classStudentRepo.find({
|
||||
where: { classId: In(accessibleClassIds), status: 'active' },
|
||||
});
|
||||
const studentIds = [...new Set(classStudents.map((item) => item.studentId))];
|
||||
if (studentIds.length === 0) return [];
|
||||
where.id = In(studentIds);
|
||||
}
|
||||
return this.repo.find({ where, order: { createdAt: 'DESC' }, relations: ['tenant'] });
|
||||
}
|
||||
|
||||
@@ -141,6 +165,74 @@ export class StudentsService {
|
||||
};
|
||||
}
|
||||
|
||||
async matchImport(
|
||||
rows: {
|
||||
name: string;
|
||||
phone?: string;
|
||||
idNumber?: string;
|
||||
gender?: string;
|
||||
ethnicity?: string;
|
||||
emergencyContact?: string;
|
||||
emergencyPhone?: string;
|
||||
organization?: string;
|
||||
supervisor?: string;
|
||||
tenantId?: number;
|
||||
}[],
|
||||
) {
|
||||
let matched = 0;
|
||||
let skipped = 0;
|
||||
for (const row of rows) {
|
||||
if (!row.name || !row.name.trim()) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
// Match by phone first, then idNumber
|
||||
let student = row.phone?.trim()
|
||||
? await this.repo.findOne({ where: { phone: row.phone.trim() } })
|
||||
: null;
|
||||
if (!student && row.idNumber?.trim()) {
|
||||
student = await this.repo.findOne({ where: { idNumber: row.idNumber.trim() } });
|
||||
}
|
||||
if (!student) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
// Update matched student with non-empty imported fields
|
||||
const updates: Partial<
|
||||
Pick<
|
||||
Student,
|
||||
| 'name'
|
||||
| 'phone'
|
||||
| 'idNumber'
|
||||
| 'gender'
|
||||
| 'ethnicity'
|
||||
| 'emergencyContact'
|
||||
| 'emergencyPhone'
|
||||
| 'organization'
|
||||
| 'supervisor'
|
||||
| 'tenantId'
|
||||
>
|
||||
> = {};
|
||||
if (row.name?.trim()) updates.name = row.name.trim();
|
||||
if (row.phone?.trim()) updates.phone = row.phone.trim();
|
||||
if (row.idNumber?.trim()) updates.idNumber = row.idNumber.trim();
|
||||
if (row.gender) updates.gender = row.gender;
|
||||
if (row.ethnicity) updates.ethnicity = row.ethnicity;
|
||||
if (row.emergencyContact) updates.emergencyContact = row.emergencyContact;
|
||||
if (row.emergencyPhone) updates.emergencyPhone = row.emergencyPhone;
|
||||
if (row.organization) updates.organization = row.organization;
|
||||
if (row.supervisor) updates.supervisor = row.supervisor;
|
||||
if (row.tenantId) updates.tenantId = row.tenantId;
|
||||
await this.repo.update(student.id, updates as Partial<Student>);
|
||||
matched++;
|
||||
}
|
||||
return {
|
||||
message: `匹配更新 ${matched} 人,跳过 ${skipped} 条(无匹配)`,
|
||||
matched,
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
async compareClasses(studentId: number) {
|
||||
const student = await this.repo.findOne({ where: { id: studentId } });
|
||||
if (!student) throw new NotFoundException('学生不存在');
|
||||
|
||||
120
apps/server/src/sync/schedule-sync.service.spec.ts
Normal file
120
apps/server/src/sync/schedule-sync.service.spec.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { ScheduleSyncService } from './schedule-sync.service';
|
||||
import { ClassSchedule } from '../entities';
|
||||
|
||||
describe('ScheduleSyncService — absence threshold', () => {
|
||||
it('updates an existing 16:00-17:00 shift so checking in before 17:00 is not absent', async () => {
|
||||
const scheduleRepo = {
|
||||
find: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
classId: 10,
|
||||
classroomId: 1,
|
||||
weekDay: 5,
|
||||
startTime: '16:00',
|
||||
endTime: '17:00',
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-31',
|
||||
status: 'active',
|
||||
} as ClassSchedule,
|
||||
]),
|
||||
};
|
||||
const classStudentRepo = {
|
||||
find: jest.fn().mockResolvedValue([{ classId: 10, studentId: 20, status: 'active' }]),
|
||||
};
|
||||
const mappingRepo = {
|
||||
find: jest.fn().mockResolvedValue([{ studentId: 20, dingUserId: 'student-1' }]),
|
||||
};
|
||||
const classRepo = {
|
||||
find: jest.fn().mockResolvedValue([{ id: 10, name: '冲刺班' }]),
|
||||
};
|
||||
const dingTalkService = {
|
||||
queryShifts: jest.fn().mockResolvedValue([{ id: 456, name: '排课_16:00-17:00' }]),
|
||||
upsertShift: jest.fn().mockResolvedValue(456),
|
||||
queryAttendanceGroups: jest.fn().mockResolvedValue([
|
||||
{ group_id: 123, group_name: '排课_冲刺班', type: 'TURN', member_count: 1 },
|
||||
]),
|
||||
updateAttendanceGroup: jest.fn().mockResolvedValue(undefined),
|
||||
createAttendanceGroup: jest.fn(),
|
||||
scheduleUsers: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const service = new ScheduleSyncService(
|
||||
scheduleRepo as never,
|
||||
classStudentRepo as never,
|
||||
mappingRepo as never,
|
||||
classRepo as never,
|
||||
dingTalkService as never,
|
||||
);
|
||||
|
||||
await service.syncAll('2026-07-10', 1);
|
||||
|
||||
expect(dingTalkService.upsertShift).toHaveBeenCalledWith(expect.objectContaining({
|
||||
id: 456,
|
||||
name: '排课_16:00-17:00',
|
||||
setting: expect.objectContaining({ absenteeism_late_minutes: 60 }),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('ScheduleSyncService — attendance machine only', () => {
|
||||
it('updates a reused attendance group with machine-only restrictions', async () => {
|
||||
const scheduleRepo = {
|
||||
find: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
classId: 10,
|
||||
classroomId: 1,
|
||||
weekDay: 1,
|
||||
startTime: '09:00',
|
||||
endTime: '16:00',
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-31',
|
||||
status: 'active',
|
||||
} as ClassSchedule,
|
||||
]),
|
||||
};
|
||||
const classStudentRepo = {
|
||||
find: jest.fn().mockResolvedValue([{ classId: 10, studentId: 20, status: 'active' }]),
|
||||
};
|
||||
const mappingRepo = {
|
||||
find: jest.fn().mockResolvedValue([{ studentId: 20, dingUserId: 'student-1' }]),
|
||||
};
|
||||
const classRepo = {
|
||||
find: jest.fn().mockResolvedValue([{ id: 10, name: '冲刺班' }]),
|
||||
};
|
||||
const dingTalkService = {
|
||||
queryShifts: jest.fn().mockResolvedValue([{ id: 456, name: '排课_09:00-16:00' }]),
|
||||
upsertShift: jest.fn().mockResolvedValue(456),
|
||||
queryAttendanceGroups: jest.fn().mockResolvedValue([
|
||||
{ group_id: 123, group_name: '排课_冲刺班', type: 'TURN', member_count: 1 },
|
||||
]),
|
||||
updateAttendanceGroup: jest.fn().mockResolvedValue(undefined),
|
||||
createAttendanceGroup: jest.fn(),
|
||||
scheduleUsers: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const service = new ScheduleSyncService(
|
||||
scheduleRepo as never,
|
||||
classStudentRepo as never,
|
||||
mappingRepo as never,
|
||||
classRepo as never,
|
||||
dingTalkService as never,
|
||||
);
|
||||
|
||||
await (service.syncAll as unknown as (
|
||||
dateFrom: string,
|
||||
days: number,
|
||||
opUserId: string,
|
||||
attendanceMachineOnly: boolean,
|
||||
) => Promise<unknown>)('2026-07-13', 1, 'manager', true);
|
||||
|
||||
expect(dingTalkService.updateAttendanceGroup).toHaveBeenCalledWith(expect.objectContaining({
|
||||
id: 123,
|
||||
name: '排课_冲刺班',
|
||||
owner: 'manager',
|
||||
shift_ids: [456],
|
||||
attendance_machine_only: true,
|
||||
}));
|
||||
expect(dingTalkService.createAttendanceGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -64,11 +64,13 @@ export class ScheduleSyncService {
|
||||
* @param dateFrom 起始日期(YYYY-MM-DD),默认今天
|
||||
* @param days 同步天数,默认 30
|
||||
* @param opUserId 钉钉操作人 userId
|
||||
* @param attendanceMachineOnly 是否关闭手机类打卡入口,仅使用考勤机
|
||||
*/
|
||||
async syncAll(
|
||||
dateFrom?: string,
|
||||
days = 30,
|
||||
opUserId = 'manager',
|
||||
attendanceMachineOnly = false,
|
||||
): Promise<ScheduleSyncResult> {
|
||||
const startDate = dateFrom || new Date().toISOString().slice(0, 10);
|
||||
const endDate = this.addDays(startDate, days);
|
||||
@@ -110,20 +112,24 @@ export class ScheduleSyncService {
|
||||
const shiftName = `排课_${startTime}-${endTime}`;
|
||||
try {
|
||||
let shiftId = shiftByName.get(shiftName);
|
||||
if (shiftId === undefined) {
|
||||
shiftId = await this.dingTalkService.upsertShift({
|
||||
name: shiftName,
|
||||
owner: opUserId,
|
||||
sections: [{
|
||||
times: [
|
||||
{ check_type: 'OnDuty', across: 0, check_time: `1970-01-01 ${startTime}:00`, free_check: false },
|
||||
{ check_type: 'OffDuty', across: 0, check_time: `1970-01-01 ${endTime}:00`, free_check: false },
|
||||
],
|
||||
}],
|
||||
setting: { is_flexible: false, serious_late_minutes: -1, absenteeism_late_minutes: -1 },
|
||||
});
|
||||
shiftByName.set(shiftName, shiftId);
|
||||
}
|
||||
const shiftParams = {
|
||||
...(shiftId === undefined ? {} : { id: shiftId }),
|
||||
name: shiftName,
|
||||
owner: opUserId,
|
||||
sections: [{
|
||||
times: [
|
||||
{ check_type: 'OnDuty' as const, across: 0, check_time: `1970-01-01 ${startTime}:00`, free_check: false },
|
||||
{ check_type: 'OffDuty' as const, across: 0, check_time: `1970-01-01 ${endTime}:00`, free_check: false },
|
||||
],
|
||||
}],
|
||||
setting: {
|
||||
is_flexible: false,
|
||||
serious_late_minutes: -1,
|
||||
absenteeism_late_minutes: this.minutesBetween(startTime, endTime),
|
||||
},
|
||||
};
|
||||
shiftId = await this.dingTalkService.upsertShift(shiftParams);
|
||||
shiftByName.set(shiftName, shiftId);
|
||||
timeToShiftId.set(key, shiftId);
|
||||
shiftCount++;
|
||||
} catch (e) {
|
||||
@@ -176,19 +182,25 @@ export class ScheduleSyncService {
|
||||
let attendanceGroupId: number;
|
||||
try {
|
||||
const cached = groupByName.get(groupName);
|
||||
const groupParams = {
|
||||
name: groupName,
|
||||
type: 'TURN' as const,
|
||||
owner: opUserId,
|
||||
members: dingUserIds.map((uid) => ({ role: 'Attendance', type: 'StaffMember' as const, user_id: uid })),
|
||||
shift_ids: [...classShiftIds],
|
||||
enable_emp_select_class: true,
|
||||
disable_check_without_schedule: false,
|
||||
disable_check_when_rest: true,
|
||||
attendance_machine_only: attendanceMachineOnly,
|
||||
};
|
||||
if (cached !== undefined) {
|
||||
attendanceGroupId = cached;
|
||||
} else {
|
||||
attendanceGroupId = await this.dingTalkService.createAttendanceGroup({
|
||||
name: groupName,
|
||||
type: 'TURN',
|
||||
owner: opUserId,
|
||||
members: dingUserIds.map((uid) => ({ role: 'Attendance', type: 'StaffMember', user_id: uid })),
|
||||
shift_ids: [...classShiftIds],
|
||||
enable_emp_select_class: true,
|
||||
disable_check_without_schedule: false,
|
||||
disable_check_when_rest: true,
|
||||
await this.dingTalkService.updateAttendanceGroup({
|
||||
...groupParams,
|
||||
id: attendanceGroupId,
|
||||
});
|
||||
} else {
|
||||
attendanceGroupId = await this.dingTalkService.createAttendanceGroup(groupParams);
|
||||
groupByName.set(groupName, attendanceGroupId);
|
||||
}
|
||||
groupCount++;
|
||||
@@ -317,6 +329,15 @@ export class ScheduleSyncService {
|
||||
return items;
|
||||
}
|
||||
|
||||
private minutesBetween(startTime: string, endTime: string): number {
|
||||
const [startHour, startMinute] = startTime.split(':').map(Number);
|
||||
const [endHour, endMinute] = endTime.split(':').map(Number);
|
||||
const start = startHour * 60 + startMinute;
|
||||
let end = endHour * 60 + endMinute;
|
||||
if (end <= start) end += 24 * 60;
|
||||
return end - start;
|
||||
}
|
||||
|
||||
private addDays(dateStr: string, days: number): string {
|
||||
const d = new Date(dateStr);
|
||||
d.setDate(d.getDate() + days);
|
||||
|
||||
22
apps/server/src/sync/sync.controller.spec.ts
Normal file
22
apps/server/src/sync/sync.controller.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { SyncController } from './sync.controller';
|
||||
|
||||
describe('SyncController — schedule sync options', () => {
|
||||
it('forwards the attendance-machine-only option', async () => {
|
||||
const syncService = {
|
||||
syncScheduleToDingTalk: jest.fn().mockResolvedValue({ syncedItems: 0 }),
|
||||
};
|
||||
const controller = new SyncController(syncService as never);
|
||||
|
||||
await (controller.syncSchedule as unknown as (
|
||||
dateFrom?: string,
|
||||
days?: string,
|
||||
attendanceMachineOnly?: string,
|
||||
) => Promise<unknown>)('2026-07-10', '30', 'true');
|
||||
|
||||
expect(syncService.syncScheduleToDingTalk).toHaveBeenCalledWith(
|
||||
'2026-07-10',
|
||||
30,
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -67,10 +67,12 @@ export class SyncController {
|
||||
async syncSchedule(
|
||||
@Query('dateFrom') dateFrom?: string,
|
||||
@Query('days') days?: string,
|
||||
@Query('attendanceMachineOnly') attendanceMachineOnly?: string,
|
||||
) {
|
||||
const result = await this.syncService.syncScheduleToDingTalk(
|
||||
dateFrom,
|
||||
days ? parseInt(days, 10) : 30,
|
||||
attendanceMachineOnly === 'true',
|
||||
);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
@@ -101,8 +101,12 @@ export class SyncService {
|
||||
// ── 排班同步 ──
|
||||
|
||||
/** 将本地排课同步到钉钉考勤排班 */
|
||||
async syncScheduleToDingTalk(dateFrom?: string, days = 30) {
|
||||
return this.scheduleSyncService.syncAll(dateFrom, days);
|
||||
async syncScheduleToDingTalk(
|
||||
dateFrom?: string,
|
||||
days = 30,
|
||||
attendanceMachineOnly = false,
|
||||
) {
|
||||
return this.scheduleSyncService.syncAll(dateFrom, days, 'manager', attendanceMachineOnly);
|
||||
}
|
||||
|
||||
/** 获取排班同步状态(当前仅返回活跃排课统计) */
|
||||
|
||||
Reference in New Issue
Block a user