From b6e308ae2a5233e810fefd8a4befcfa465f9537f Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sun, 5 Jul 2026 19:44:10 +0800 Subject: [PATCH] feat: add Attendance module with batch, summary, calendar, and dingtalk endpoints --- apps/server/src/app.module.ts | 6 + .../src/attendance/attendance.controller.ts | 97 +++++++++++ .../src/attendance/attendance.module.ts | 17 ++ .../src/attendance/attendance.service.ts | 162 ++++++++++++++++++ .../src/attendance/dto/attendance.dto.ts | 85 +++++++++ .../src/entities/attendance-record.entity.ts | 52 ++++++ .../entities/ding-attendance-raw.entity.ts | 65 +++++++ apps/server/src/entities/index.ts | 2 + 8 files changed, 486 insertions(+) create mode 100644 apps/server/src/attendance/attendance.controller.ts create mode 100644 apps/server/src/attendance/attendance.module.ts create mode 100644 apps/server/src/attendance/attendance.service.ts create mode 100644 apps/server/src/attendance/dto/attendance.dto.ts create mode 100644 apps/server/src/entities/attendance-record.entity.ts create mode 100644 apps/server/src/entities/ding-attendance-raw.entity.ts diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index b2b2f6a..373895b 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -20,6 +20,8 @@ import { Permission, Role, ClassSchedule, + AttendanceRecord, + DingAttendanceRaw, } from './entities'; import { AuthModule } from './auth/auth.module'; import { RbacModule } from './rbac/rbac.module'; @@ -35,6 +37,7 @@ import { DepositsModule } from './deposits/deposits.module'; import { ClassroomsModule } from './classrooms/classrooms.module'; import { ClassesModule } from './classes/classes.module'; import { TenantsModule } from './tenants/tenants.module'; +import { AttendanceModule } from './attendance/attendance.module'; import { SchedulesModule } from './schedules/schedules.module'; import { ClassroomRentalsModule } from './classroom-rentals/classroom-rentals.module'; @@ -69,6 +72,8 @@ import { ClassroomRentalsModule } from './classroom-rentals/classroom-rentals.mo Permission, Role, ClassSchedule, + AttendanceRecord, + DingAttendanceRaw, ]; if (dbType === 'mysql') { return { @@ -102,6 +107,7 @@ import { ClassroomRentalsModule } from './classroom-rentals/classroom-rentals.mo OperationLogsModule, DepositsModule, ClassroomsModule, + AttendanceModule, ClassesModule, TenantsModule, SchedulesModule, diff --git a/apps/server/src/attendance/attendance.controller.ts b/apps/server/src/attendance/attendance.controller.ts new file mode 100644 index 0000000..0f052be --- /dev/null +++ b/apps/server/src/attendance/attendance.controller.ts @@ -0,0 +1,97 @@ +import { + Controller, + Get, + Post, + Body, + Param, + Query, + UseGuards, + Request, +} from '@nestjs/common'; +import { AttendanceService } from './attendance.service'; +import { + BatchCreateAttendanceDto, + AttendanceSummaryQueryDto, + AttendanceCalendarQueryDto, + QueryDingRawDto, + MatchDingRecordDto, +} from './dto/attendance.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { OperationLogsService } from '../operation-logs/operation-logs.service'; +import { extractRequestInfo } from '../common/request-utils'; +import { RequirePermission } from '../auth/decorators/permission.decorator'; + +@UseGuards(JwtAuthGuard) +@Controller() +export class AttendanceController { + constructor( + private readonly service: AttendanceService, + private readonly logService: OperationLogsService, + ) {} + + // ── Batch create attendance records ── + @Post('attendance-records/batch') + @RequirePermission('attendance:create') + async batchCreate( + @Body() dto: BatchCreateAttendanceDto, + @Request() req: any, + ) { + const { ipAddress, userAgent } = extractRequestInfo(req); + const result = await this.service.batchCreate(dto); + await this.logService.log({ + userId: req.user?.id, + username: req.user?.username, + module: '考勤管理', + action: '批量录入考勤', + detail: `共 ${result.count} 条`, + ipAddress, + userAgent, + }); + return result; + } + + // ── Attendance summary ── + @Get('attendance-records/summary') + @RequirePermission('attendance:view') + getSummary(@Query() query: AttendanceSummaryQueryDto) { + return this.service.getSummary(query); + } + + // ── Attendance calendar ── + @Get('attendance-records/calendar') + @RequirePermission('attendance:view') + getCalendar(@Query() query: AttendanceCalendarQueryDto) { + return this.service.getCalendar(query); + } + + // ── DingAttendance raw records ── + @Get('ding-attendance-raw') + @RequirePermission('attendance:view') + getDingRaw(@Query() query: QueryDingRawDto) { + return this.service.getDingRaw(query); + } + + // ── Match a dingtalk record to a student ── + @Post('ding-attendance-raw/:id/match') + @RequirePermission('attendance:edit') + async matchDingRecord( + @Param('id') id: string, + @Body() dto: MatchDingRecordDto, + @Request() req: any, + ) { + const { ipAddress, userAgent } = extractRequestInfo(req); + const result = await this.service.matchDingRecord(+id, dto); + await this.logService.log({ + userId: req.user?.id, + username: req.user?.username, + module: '考勤管理', + action: '匹配考勤记录', + targetId: +id, + targetType: 'dingAttendanceRaw', + detail: `匹配到学生 ${dto.studentId}`, + ipAddress, + userAgent, + }); + return result; + } +} diff --git a/apps/server/src/attendance/attendance.module.ts b/apps/server/src/attendance/attendance.module.ts new file mode 100644 index 0000000..9a18029 --- /dev/null +++ b/apps/server/src/attendance/attendance.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AttendanceRecord, DingAttendanceRaw, Student, Class } from '../entities'; +import { AttendanceService } from './attendance.service'; +import { AttendanceController } from './attendance.controller'; +import { OperationLogsModule } from '../operation-logs/operation-logs.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([AttendanceRecord, DingAttendanceRaw, Student, Class]), + OperationLogsModule, + ], + controllers: [AttendanceController], + providers: [AttendanceService], + exports: [AttendanceService], +}) +export class AttendanceModule {} diff --git a/apps/server/src/attendance/attendance.service.ts b/apps/server/src/attendance/attendance.service.ts new file mode 100644 index 0000000..f81d2a5 --- /dev/null +++ b/apps/server/src/attendance/attendance.service.ts @@ -0,0 +1,162 @@ +import { + Injectable, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, In, Between } from 'typeorm'; +import { AttendanceRecord, DingAttendanceRaw } from '../entities'; +import { + BatchCreateAttendanceDto, + AttendanceSummaryQueryDto, + AttendanceCalendarQueryDto, + QueryDingRawDto, + MatchDingRecordDto, +} from './dto/attendance.dto'; + +@Injectable() +export class AttendanceService { + constructor( + @InjectRepository(AttendanceRecord) + private attendanceRepo: Repository, + @InjectRepository(DingAttendanceRaw) + private dingRawRepo: Repository, + ) {} + + // ── Batch create attendance records ── + async batchCreate(dto: BatchCreateAttendanceDto) { + if (!dto.records || dto.records.length === 0) { + throw new BadRequestException('records array must not be empty'); + } + + const entities = dto.records.map((r) => + this.attendanceRepo.create({ + studentId: r.studentId, + classId: r.classId ?? undefined, + attendanceDate: r.attendanceDate, + session: r.session, + status: r.status, + remark: r.remark, + }), + ); + + const saved = await this.attendanceRepo.save(entities); + return { count: saved.length, records: saved }; + } + + // ── Attendance summary ── + async getSummary(query: AttendanceSummaryQueryDto) { + const qb = this.attendanceRepo.createQueryBuilder('ar'); + + if (query.classId) { + qb.andWhere('ar.classId = :classId', { classId: query.classId }); + } + if (query.dateFrom) { + qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom }); + } + if (query.dateTo) { + qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo }); + } + + const rows = await qb.getMany(); + + const total = rows.length; + const present = rows.filter((r) => r.status === 'present').length; + const late = rows.filter((r) => r.status === 'late').length; + const absent = rows.filter((r) => r.status === 'absent').length; + const leave = rows.filter((r) => r.status === 'leave').length; + const presentRate = total > 0 ? Number(((present / total) * 100).toFixed(1)) : 0; + + return { total, present, late, absent, leave, presentRate }; + } + + // ── Attendance calendar ── + async getCalendar(query: AttendanceCalendarQueryDto) { + const { classId, weekStart } = query; + + if (!weekStart) { + // Default to the Monday of the current week + const now = new Date(); + const day = now.getDay(); + const diff = day === 0 ? -6 : 1 - day; // Monday offset + const monday = new Date(now); + monday.setDate(now.getDate() + diff); + const mondayStr = monday.toISOString().slice(0, 10); + + return this.buildCalendar(classId, mondayStr); + } + + return this.buildCalendar(classId, weekStart); + } + + private async buildCalendar(classId: number, weekStart: string) { + // Compute weekEnd (Sunday = weekStart + 6 days) + const start = new Date(weekStart); + const end = new Date(start); + end.setDate(start.getDate() + 6); + const endStr = end.toISOString().slice(0, 10); + + // Fetch attendance records for the week + const records = await this.attendanceRepo.find({ + where: { + classId, + attendanceDate: Between(weekStart, endStr), + }, + relations: ['student'], + order: { attendanceDate: 'ASC', session: 'ASC' }, + }); + + // Group by studentId + const studentMap = new Map< + number, + { + studentId: number; + studentName: string; + days: Array<{ date: string; session: string; status: string }>; + } + >(); + + for (const r of records) { + if (!studentMap.has(r.studentId)) { + studentMap.set(r.studentId, { + studentId: r.studentId, + studentName: r.student?.name ?? `Student#${r.studentId}`, + days: [], + }); + } + studentMap.get(r.studentId)!.days.push({ + date: r.attendanceDate, + session: r.session, + status: r.status, + }); + } + + return Array.from(studentMap.values()); + } + + // ── DingAttendance raw records ── + async getDingRaw(query: QueryDingRawDto) { + const where: any = {}; + if (query.matchStatus) { + where.matchStatus = query.matchStatus; + } + + return this.dingRawRepo.find({ + where, + relations: ['matchedStudent'], + order: { attendanceDate: 'DESC', checkInTime: 'ASC' }, + }); + } + + // ── Match a dingtalk record to a student ── + async matchDingRecord(id: number, dto: MatchDingRecordDto) { + const record = await this.dingRawRepo.findOne({ where: { id } }); + if (!record) { + throw new NotFoundException(`DingAttendanceRaw ${id} not found`); + } + + record.matchedStudentId = dto.studentId; + record.matchStatus = '已匹配'; + return this.dingRawRepo.save(record); + } +} diff --git a/apps/server/src/attendance/dto/attendance.dto.ts b/apps/server/src/attendance/dto/attendance.dto.ts new file mode 100644 index 0000000..5e22169 --- /dev/null +++ b/apps/server/src/attendance/dto/attendance.dto.ts @@ -0,0 +1,85 @@ +import { + IsArray, + IsOptional, + IsString, + IsInt, + IsDateString, + IsIn, + ValidateNested, + IsNotEmpty, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class AttendanceRecordItem { + @IsInt() + @IsNotEmpty() + studentId: number; + + @IsOptional() + @IsInt() + classId?: number; + + @IsDateString() + @IsNotEmpty() + attendanceDate: string; + + @IsString() + @IsIn(['morning', 'afternoon', 'evening']) + @IsNotEmpty() + session: string; + + @IsString() + @IsIn(['present', 'late', 'absent', 'leave']) + @IsNotEmpty() + status: string; + + @IsOptional() + @IsString() + remark?: string; +} + +export class BatchCreateAttendanceDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AttendanceRecordItem) + records: AttendanceRecordItem[]; +} + +export class AttendanceSummaryQueryDto { + @IsOptional() + @IsInt() + @Type(() => Number) + classId?: number; + + @IsOptional() + @IsDateString() + dateFrom?: string; + + @IsOptional() + @IsDateString() + dateTo?: string; +} + +export class AttendanceCalendarQueryDto { + @IsInt() + @Type(() => Number) + @IsNotEmpty() + classId: number; + + @IsOptional() + @IsDateString() + weekStart?: string; +} + +export class QueryDingRawDto { + @IsOptional() + @IsString() + @IsIn(['未处理', '已匹配', '待匹配']) + matchStatus?: string; +} + +export class MatchDingRecordDto { + @IsInt() + @IsNotEmpty() + studentId: number; +} diff --git a/apps/server/src/entities/attendance-record.entity.ts b/apps/server/src/entities/attendance-record.entity.ts new file mode 100644 index 0000000..5178969 --- /dev/null +++ b/apps/server/src/entities/attendance-record.entity.ts @@ -0,0 +1,52 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Student } from './student.entity'; +import { Class } from './class.entity'; + +@Entity('attendance_records') +@Index(['classId', 'attendanceDate']) +@Index(['studentId', 'attendanceDate']) +export class AttendanceRecord { + @PrimaryGeneratedColumn() + id: number; + + @Column({ name: 'student_id', type: 'integer' }) + studentId: number; + + @ManyToOne(() => Student, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'student_id' }) + student: Student; + + @Column({ name: 'class_id', type: 'integer', nullable: true }) + classId: number; + + @ManyToOne(() => Class, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'class_id' }) + class: Class; + + @Column({ name: 'attendance_date', type: 'date' }) + attendanceDate: string; + + @Column({ length: 20 }) + session: string; + + @Column({ length: 20 }) + status: string; + + @Column({ length: 200, nullable: true }) + remark: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/apps/server/src/entities/ding-attendance-raw.entity.ts b/apps/server/src/entities/ding-attendance-raw.entity.ts new file mode 100644 index 0000000..6aed4f8 --- /dev/null +++ b/apps/server/src/entities/ding-attendance-raw.entity.ts @@ -0,0 +1,65 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Student } from './student.entity'; + +@Entity('ding_attendance_raw') +@Index(['attendanceDate']) +@Index(['matchStatus']) +export class DingAttendanceRaw { + @PrimaryGeneratedColumn() + id: number; + + @Column({ name: 'ding_user_id', length: 100 }) + dingUserId: string; + + @Column({ name: 'user_name', length: 100 }) + userName: string; + + @Column({ name: 'attendance_date', type: 'date' }) + attendanceDate: string; + + @Column({ name: 'ding_id', length: 100, unique: true }) + dingId: string; + + @Column({ name: 'check_in_time', type: 'datetime', nullable: true }) + checkInTime: Date; + + @Column({ name: 'check_out_time', type: 'datetime', nullable: true }) + checkOutTime: Date; + + @Column({ name: 'attendance_type', length: 20 }) + attendanceType: string; + + @Column({ name: 'time_result', length: 20 }) + timeResult: string; + + @Column({ name: 'location_result', length: 20, nullable: true }) + locationResult: string; + + @Column({ name: 'match_status', length: 20, default: '未处理' }) + matchStatus: string; + + @Column({ name: 'matched_student_id', type: 'integer', nullable: true }) + matchedStudentId: number; + + @ManyToOne(() => Student, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'matched_student_id' }) + matchedStudent: Student; + + @Column({ name: 'raw_data', type: 'text', nullable: true }) + rawData: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/apps/server/src/entities/index.ts b/apps/server/src/entities/index.ts index dacefa4..7d287c6 100644 --- a/apps/server/src/entities/index.ts +++ b/apps/server/src/entities/index.ts @@ -17,3 +17,5 @@ export { Class, ClassType, ClassStatus } from './class.entity'; export { ClassStudent } from './class-student.entity'; export { ClassTeacher, TeacherRoleType } from './class-teacher.entity'; export { ClassSchedule, ScheduleType } from './class-schedule.entity'; +export { AttendanceRecord } from './attendance-record.entity'; +export { DingAttendanceRaw } from './ding-attendance-raw.entity';