diff --git a/apps/server/src/attendance/attendance.controller.ts b/apps/server/src/attendance/attendance.controller.ts index 19dbabb..b0c976d 100644 --- a/apps/server/src/attendance/attendance.controller.ts +++ b/apps/server/src/attendance/attendance.controller.ts @@ -18,6 +18,7 @@ import { QueryAttendanceRecordsDto, QueryDingRawDto, MatchDingRecordDto, + AttendanceReportQueryDto, } from './dto/attendance.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; @@ -56,7 +57,7 @@ export class AttendanceController { // ── Export attendance records ── @Get('attendance-records/export') - @RequirePermission('attendance:view') + @RequirePermission('attendance:export') async exportRecords( @Query() query: QueryAttendanceRecordsDto, @Res() res: Response, @@ -167,4 +168,83 @@ export class AttendanceController { }); return result; } -} + + // ── Attendance class-based report export ── + @Get('attendance-records/report') + @RequirePermission('attendance:export') + async exportReport( + @Query() query: AttendanceReportQueryDto, + @Res() res: Response, + @Request() req: any, + ) { + const reportData = await this.service.getReport(query); + + const workbook = new ExcelJS.Workbook(); + const ws = workbook.addWorksheet('考勤统计报表'); + ws.columns = [ + { header: '班级名称', key: 'className', width: 30 }, + { header: '总记录数', key: 'total', width: 12 }, + { header: '出勤', key: 'present', width: 10 }, + { header: '出勤率', key: 'presentRate', width: 10 }, + { header: '缺勤', key: 'absent', width: 10 }, + { header: '缺勤率', key: 'absentRate', width: 10 }, + { header: '迟到', key: 'late', width: 10 }, + { header: '迟到率', key: 'lateRate', width: 10 }, + { header: '请假', key: 'leave', width: 10 }, + { header: '请假率', key: 'leaveRate', width: 10 }, + ]; + ws.getRow(1).font = { bold: true }; + ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } }; + + for (const row of reportData) { + ws.addRow({ + className: row.className, + total: row.total, + present: row.present, + presentRate: `${row.presentRate}%`, + absent: row.absent, + absentRate: `${row.absentRate}%`, + late: row.late, + lateRate: `${row.lateRate}%`, + leave: row.leave, + leaveRate: `${row.leaveRate}%`, + }); + } + + // Audit log + const { ipAddress, userAgent } = extractRequestInfo(req); + await this.logService.log({ + userId: req.user?.id, + username: req.user?.username, + module: '考勤管理', + action: '导出考勤报表', + detail: `classId=${query.classId || '全部'} ${query.dateFrom || ''}~${query.dateTo || ''}`, + ipAddress, + userAgent, + }); + + 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(); + } + + // ── Abnormal attendance alerts ── + @Get('attendance-records/alerts') + @RequirePermission('attendance:view') + getAlerts( + @Query('days') days?: string, + @Query('threshold') threshold?: string, + ) { + return this.service.getAlerts( + days ? +days : 14, + threshold ? +threshold : 3, + ); + } + + @Post('ding-attendance-raw/auto-match') + @RequirePermission('attendance:edit') + async autoMatch() { + return this.service.autoMatchDingRecords(); + } + } diff --git a/apps/server/src/attendance/attendance.module.ts b/apps/server/src/attendance/attendance.module.ts index 9a18029..6620b20 100644 --- a/apps/server/src/attendance/attendance.module.ts +++ b/apps/server/src/attendance/attendance.module.ts @@ -4,11 +4,12 @@ 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'; - +import { DepartmentsModule } from '../departments/departments.module'; @Module({ imports: [ TypeOrmModule.forFeature([AttendanceRecord, DingAttendanceRaw, Student, Class]), OperationLogsModule, + DepartmentsModule, ], controllers: [AttendanceController], providers: [AttendanceService], diff --git a/apps/server/src/attendance/attendance.service.ts b/apps/server/src/attendance/attendance.service.ts index 7782c5e..bffbb24 100644 --- a/apps/server/src/attendance/attendance.service.ts +++ b/apps/server/src/attendance/attendance.service.ts @@ -5,13 +5,15 @@ import { } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In, Between } from 'typeorm'; -import { AttendanceRecord, DingAttendanceRaw, Class } from '../entities'; +import { AttendanceRecord, DingAttendanceRaw, Class, Student } from '../entities'; +import { CampusScope } from '../common/campus-scope'; import { BatchCreateAttendanceDto, AttendanceSummaryQueryDto, AttendanceCalendarQueryDto, QueryDingRawDto, MatchDingRecordDto, + AttendanceReportQueryDto, } from './dto/attendance.dto'; @Injectable() @@ -23,6 +25,9 @@ export class AttendanceService { private dingRawRepo: Repository, @InjectRepository(Class) private classRepo: Repository, + @InjectRepository(Student) + private studentRepo: Repository, + private readonly scope: CampusScope, ) {} // ── Batch create attendance records ── @@ -50,7 +55,10 @@ export class AttendanceService { // ── Attendance summary ── async getSummary(query: AttendanceSummaryQueryDto) { const qb = this.attendanceRepo.createQueryBuilder('ar'); - + const scopeIds = await this.scope.getScopeDepartmentIds(); + if (scopeIds) { + qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds }); + } if (query.classId) { qb.andWhere('ar.classId = :classId', { classId: query.classId }); } @@ -151,11 +159,14 @@ export class AttendanceService { const page = query.page || 1; const pageSize = query.pageSize || 20; - const qb = this.attendanceRepo - .createQueryBuilder('ar') - .leftJoinAndSelect('ar.student', 'student') - .leftJoinAndSelect('ar.class', 'class'); + const qb = this.attendanceRepo.createQueryBuilder('ar'); + const scopeIds = await this.scope.getScopeDepartmentIds(); + if (scopeIds) { + qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds }); + } + qb.leftJoinAndSelect('ar.student', 'student') + .leftJoinAndSelect('ar.class', 'class'); if (query.classId) { qb.andWhere('ar.classId = :classId', { classId: query.classId }); } @@ -184,17 +195,25 @@ export class AttendanceService { // ── Get distinct classes with attendance records ── async getClasses() { - const rows = await this.attendanceRepo + const qb = this.attendanceRepo .createQueryBuilder('ar') .select('DISTINCT ar.classId', 'classId') - .where('ar.classId IS NOT NULL') + .where('ar.classId IS NOT NULL'); + + const scopeIds = await this.scope.getScopeDepartmentIds(); + if (scopeIds) { + qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds }); + } + + const rows = await qb .orderBy('ar.classId', 'ASC') .getRawMany(); const classIds = rows.map((r) => r.classId).filter(Boolean) as number[]; if (classIds.length === 0) return []; - const classes = await this.classRepo.find({ where: { id: In(classIds) } }); + const where = await this.scope.filter({ id: In(classIds) }); + const classes = await this.classRepo.find({ where }); const nameMap = new Map(classes.map((c) => [c.id, c.name])); return classIds.map((id) => ({ classId: id, className: nameMap.get(id) || `班级${id}` })); } @@ -207,7 +226,7 @@ export class AttendanceService { } return this.dingRawRepo.find({ - where, + where: await this.scope.filter(where), relations: ['matchedStudent'], order: { attendanceDate: 'DESC', checkInTime: 'ASC' }, }); @@ -225,6 +244,28 @@ export class AttendanceService { return this.dingRawRepo.save(record); } + // ── Auto-match unmatched dingtalk records by phone/idCard/name ── + async autoMatchDingRecords(): Promise<{ matched: number; total: number }> { + const unmatched = await this.dingRawRepo.find({ + where: { matchStatus: '未处理' }, + }); + + if (unmatched.length === 0) return { matched: 0, total: 0 }; + + let matched = 0; + for (const record of unmatched) { + const student = await this.studentRepo.findOne({ where: { name: record.userName } }); + if (student) { + record.matchStatus = '已匹配'; + record.matchedStudentId = student.id; + await this.dingRawRepo.save(record); + matched++; + } + } + + return { matched, total: unmatched.length }; + } + // ── Export all attendance records with filters (no pagination) ── async findAllForExport(query: { classId?: number; @@ -234,11 +275,14 @@ export class AttendanceService { status?: string; source?: string; }) { - const qb = this.attendanceRepo - .createQueryBuilder('ar') - .leftJoinAndSelect('ar.student', 'student') - .leftJoinAndSelect('ar.class', 'class'); + const qb = this.attendanceRepo.createQueryBuilder('ar'); + const scopeIds = await this.scope.getScopeDepartmentIds(); + if (scopeIds) { + qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds }); + } + qb.leftJoinAndSelect('ar.student', 'student') + .leftJoinAndSelect('ar.class', 'class'); if (query.classId) { qb.andWhere('ar.classId = :classId', { classId: query.classId }); } @@ -262,4 +306,117 @@ export class AttendanceService { return qb.getMany(); } -} + + // ── Class-based attendance report ── + async getReport(query: AttendanceReportQueryDto) { + const qb = this.attendanceRepo.createQueryBuilder('ar'); + const scopeIds = await this.scope.getScopeDepartmentIds(); + if (scopeIds) { + qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds }); + } + + qb.leftJoin('ar.class', 'class') + .select('class.id', 'classId') + .addSelect('class.name', 'className') + .addSelect('ar.status', 'status') + .addSelect('COUNT(*)', 'count'); + 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 }); + } + + qb.groupBy('class.id').addGroupBy('class.name').addGroupBy('ar.status'); + const rawRows = await qb.getRawMany(); + + // Aggregate by class + const classMap = new Map(); + + for (const row of rawRows) { + if (!row.classId) continue; + if (!classMap.has(row.classId)) { + classMap.set(row.classId, { + classId: row.classId, + className: row.className || `班级#${row.classId}`, + present: 0, + absent: 0, + late: 0, + leave: 0, + }); + } + const entry = classMap.get(row.classId)!; + const count = parseInt(row.count, 10); + if (row.status === 'present') entry.present += count; + else if (row.status === 'absent') entry.absent += count; + else if (row.status === 'late') entry.late += count; + else if (row.status === 'leave') entry.leave += count; + } + + return Array.from(classMap.values()).map((entry) => { + const total = entry.present + entry.absent + entry.late + entry.leave; + return { + ...entry, + total, + presentRate: total > 0 ? ((entry.present / total) * 100).toFixed(1) : '0.0', + absentRate: total > 0 ? ((entry.absent / total) * 100).toFixed(1) : '0.0', + lateRate: total > 0 ? ((entry.late / total) * 100).toFixed(1) : '0.0', + leaveRate: total > 0 ? ((entry.leave / total) * 100).toFixed(1) : '0.0', + }; + }); + } + // ── Attendance alerts: detect consecutive absences/late ── + async getAlerts(days: number = 14, threshold: number = 3) { + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - days); + const cutoffStr = cutoff.toISOString().slice(0, 10); + + const qb = this.attendanceRepo + .createQueryBuilder('a') + .leftJoinAndSelect('a.student', 'student') + .leftJoinAndSelect('a.class', 'class'); + + const scopeIds = await this.scope.getScopeDepartmentIds(); + if (scopeIds) { + qb.andWhere('a.departmentId IN (:...scopeIds)', { scopeIds }); + } + + 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(); + + const alerts: Array<{ + studentId: number; studentName: string; className: string; + type: string; count: number; lastDate: string; + }> = []; + + let current: typeof alerts[0] | null = null; + for (const r of records) { + const name = (r.student as any)?.name || ''; + const className = (r.class as any)?.name || ''; + const status = r.status === 'absent' ? '缺勤' : '迟到'; + if (current && current.studentId === r.studentId && current.type === status) { + current.count++; + if (r.attendanceDate > current.lastDate) current.lastDate = r.attendanceDate; + } else { + if (current && current.count >= threshold) alerts.push({ ...current }); + current = { studentId: r.studentId, studentName: name, className, type: status, count: 1, lastDate: r.attendanceDate }; + } + } + if (current && current.count >= threshold) alerts.push(current); + return alerts; + } + } diff --git a/apps/server/src/attendance/dto/attendance.dto.ts b/apps/server/src/attendance/dto/attendance.dto.ts index e22bd4e..631da3b 100644 --- a/apps/server/src/attendance/dto/attendance.dto.ts +++ b/apps/server/src/attendance/dto/attendance.dto.ts @@ -125,3 +125,18 @@ export class MatchDingRecordDto { @IsNotEmpty() studentId: number; } + +export class AttendanceReportQueryDto { + @IsOptional() + @IsInt() + @Type(() => Number) + classId?: number; + + @IsOptional() + @IsDateString() + dateFrom?: string; + + @IsOptional() + @IsDateString() + dateTo?: string; +} diff --git a/apps/server/src/bills/bills.module.ts b/apps/server/src/bills/bills.module.ts index 14dfd6c..d7e3217 100644 --- a/apps/server/src/bills/bills.module.ts +++ b/apps/server/src/bills/bills.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { NotificationsModule } from '../notifications/notifications.module'; +import { DepartmentsModule } from '../departments/departments.module'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Bill } from '../entities/bill.entity'; import { BillItem } from '../entities/bill-item.entity'; @@ -26,6 +27,7 @@ import { BillsController } from './bills.controller'; Student, ]), NotificationsModule, + DepartmentsModule, ], controllers: [BillsController], providers: [BillsService, BillsExportService], diff --git a/apps/server/src/bills/bills.service.ts b/apps/server/src/bills/bills.service.ts index c6f18ec..4d91e68 100644 --- a/apps/server/src/bills/bills.service.ts +++ b/apps/server/src/bills/bills.service.ts @@ -9,6 +9,7 @@ import { Occupancy } from '../entities/occupancy.entity'; import { Room } from '../entities/room.entity'; import { Deposit } from '../entities/deposit.entity'; import { GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto'; +import { CampusScope } from '../common/campus-scope'; @Injectable() export class BillsService { @@ -21,6 +22,7 @@ export class BillsService { @InjectRepository(Room) private roomRepo: Repository, @InjectRepository(Deposit) private depositRepo: Repository, private dataSource: DataSource, + private readonly scope: CampusScope, ) {} /** @@ -216,10 +218,12 @@ export class BillsService { studentId?: number; status?: string; }) { + const scopeIds = await this.scope.getScopeDepartmentIds(); const qb = this.billRepo .createQueryBuilder('b') .leftJoinAndSelect('b.student', 'student') .orderBy('b.generatedAt', 'DESC'); + if (scopeIds) qb.andWhere('b.departmentId IN (:...scopeIds)', { scopeIds }); if (query?.periodStart) qb.andWhere('b.periodStart = :ps', { ps: query.periodStart }); if (query?.periodEnd) qb.andWhere('b.periodEnd = :pe', { pe: query.periodEnd }); if (query?.studentId) qb.andWhere('b.studentId = :sid', { sid: query.studentId }); diff --git a/apps/server/src/classes/classes.module.ts b/apps/server/src/classes/classes.module.ts index a99c715..29d92f0 100644 --- a/apps/server/src/classes/classes.module.ts +++ b/apps/server/src/classes/classes.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { DepartmentsModule } from '../departments/departments.module'; import { Class, ClassStudent, ClassTeacher } from '../entities'; import { ClassesService } from './classes.service'; import { ClassesController } from './classes.controller'; @@ -7,7 +8,7 @@ import { OperationLogsModule } from '../operation-logs/operation-logs.module'; import { NotificationsModule } from '../notifications/notifications.module'; @Module({ - imports: [TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher]), OperationLogsModule, NotificationsModule], + imports: [TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher]), OperationLogsModule, NotificationsModule, DepartmentsModule], controllers: [ClassesController], providers: [ClassesService], exports: [ClassesService], diff --git a/apps/server/src/classes/classes.service.ts b/apps/server/src/classes/classes.service.ts index 53ed306..303b1d2 100644 --- a/apps/server/src/classes/classes.service.ts +++ b/apps/server/src/classes/classes.service.ts @@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In, Like } from 'typeorm'; import { Class, ClassStudent, ClassTeacher } from '../entities'; import { CreateClassDto, UpdateClassDto, QueryClassDto, AddTeacherDto } from './dto/class.dto'; +import { CampusScope } from '../common/campus-scope'; interface RawStudentCount { classId: string; @@ -18,14 +19,16 @@ export class ClassesService { private classStudentRepo: Repository, @InjectRepository(ClassTeacher) private classTeacherRepo: Repository, + private readonly scope: CampusScope, ) {} async findAll(query: QueryClassDto) { - const where: Record = {}; + let where: Record = {}; if (query.departmentId) where.departmentId = query.departmentId; if (query.status) where.status = query.status; if (query.classType) where.classType = query.classType; if (query.keyword) where.name = Like(`%${query.keyword}%`); + where = await this.scope.filter(where); const classes = await this.classRepo.find({ where, diff --git a/apps/server/src/classroom-rentals/classroom-rentals.module.ts b/apps/server/src/classroom-rentals/classroom-rentals.module.ts index 10b317e..ab1bb71 100644 --- a/apps/server/src/classroom-rentals/classroom-rentals.module.ts +++ b/apps/server/src/classroom-rentals/classroom-rentals.module.ts @@ -3,12 +3,14 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { ClassroomRental } from '../entities/classroom-rental.entity'; import { Classroom } from '../entities/classroom.entity'; import { Tenant } from '../entities/tenant.entity'; +import { ClassSchedule } from '../entities/class-schedule.entity'; import { ClassroomRentalsService } from './classroom-rentals.service'; import { ClassroomRentalsController } from './classroom-rentals.controller'; import { OperationLogsModule } from '../operation-logs/operation-logs.module'; +import { DepartmentsModule } from '../departments/departments.module'; @Module({ - imports: [TypeOrmModule.forFeature([ClassroomRental, Classroom, Tenant]), OperationLogsModule], + imports: [TypeOrmModule.forFeature([ClassroomRental, Classroom, Tenant, ClassSchedule]), OperationLogsModule, DepartmentsModule], controllers: [ClassroomRentalsController], providers: [ClassroomRentalsService], exports: [ClassroomRentalsService], diff --git a/apps/server/src/classroom-rentals/classroom-rentals.service.ts b/apps/server/src/classroom-rentals/classroom-rentals.service.ts index e3d3a79..3416304 100644 --- a/apps/server/src/classroom-rentals/classroom-rentals.service.ts +++ b/apps/server/src/classroom-rentals/classroom-rentals.service.ts @@ -9,7 +9,9 @@ import { Repository, Not } from 'typeorm'; import { ClassroomRental } from '../entities/classroom-rental.entity'; import { Classroom } from '../entities/classroom.entity'; import { Tenant } from '../entities/tenant.entity'; +import { ClassSchedule } from '../entities/class-schedule.entity'; import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto'; +import { CampusScope } from '../common/campus-scope'; import * as path from 'path'; import * as fs from 'fs'; @@ -29,10 +31,13 @@ const COLOR_PALETTE = [ @Injectable() export class ClassroomRentalsService { + constructor( @InjectRepository(ClassroomRental) private repo: Repository, @InjectRepository(Classroom) private classroomRepo: Repository, @InjectRepository(Tenant) private tenantRepo: Repository, + @InjectRepository(ClassSchedule) private scheduleRepo: Repository, + private readonly scope: CampusScope, ) {} get uploadDir(): string { @@ -52,15 +57,16 @@ export class ClassroomRentalsService { month?: string; includeEnded?: boolean; }) { + const scopeIds = await this.scope.getScopeDepartmentIds(); const qb = this.repo .createQueryBuilder('r') .leftJoinAndSelect('r.classroom', 'classroom') .leftJoinAndSelect('r.tenant', 'tenant') .orderBy('r.startDate', 'DESC'); + if (scopeIds) qb.andWhere('r.departmentId IN (:...scopeIds)', { scopeIds }); if (query?.classroomId) qb.andWhere('r.classroomId = :cid', { cid: query.classroomId }); if (query?.tenantId) qb.andWhere('r.tenantId = :tid', { tid: query.tenantId }); if (query?.month) { - // month 格式 2026-06,查询当月有重叠的租赁 const [y, m] = query.month.split('-').map(Number); const first = `${y}-${String(m).padStart(2, '0')}-01`; const lastDay = new Date(y, m, 0).getDate(); @@ -270,6 +276,7 @@ export class ClassroomRentalsService { const day = d.getDate(); if (!matrix[rental.classroomId]) continue; matrix[rental.classroomId][day] = { + scheduleType: 'RENTAL', rentalId: rental.id, tenantId: rental.tenantId, tenantName: rental.tenant?.name || '未知', @@ -280,6 +287,37 @@ export class ClassroomRentalsService { } } + // ── Overlay internal class schedules ── + const schedules = await this.scheduleRepo + .createQueryBuilder('s') + .leftJoinAndSelect('s.class', 'class') + .leftJoinAndSelect('s.teacher', 'teacher') + .where('s.status = :active', { active: 'active' }) + .andWhere('s.scheduleType = :type', { type: 'INTERNAL' }) + .andWhere('s.startDate <= :last AND s.endDate >= :first', { first, last }) + .getMany(); + + for (const sched of schedules) { + if (!sched.classroomId) continue; + const schedStart = new Date(Math.max(new Date(sched.startDate).getTime(), new Date(first).getTime())); + const schedEnd = new Date(Math.min(new Date(sched.endDate).getTime(), new Date(last).getTime())); + for (let d = new Date(schedStart); d <= schedEnd; d.setDate(d.getDate() + 1)) { + const dow = d.getDay() === 0 ? 7 : d.getDay(); + if (dow !== sched.weekDay) continue; + const day = d.getDate(); + if (!matrix[sched.classroomId]) continue; + matrix[sched.classroomId][day] = { + scheduleType: 'INTERNAL', + scheduleId: sched.id, + className: (sched.class as any)?.name || '', + subject: sched.subject, + teacherName: (sched.teacher as any)?.name || '', + startTime: sched.startTime, + endTime: sched.endTime, + color: '#52c41a', + }; + } + } // 统计 for (const cls of classrooms) { const rented = Object.keys(matrix[cls.id]).length; diff --git a/apps/server/src/classrooms/classrooms.controller.ts b/apps/server/src/classrooms/classrooms.controller.ts index 1fd982e..da3aca4 100644 --- a/apps/server/src/classrooms/classrooms.controller.ts +++ b/apps/server/src/classrooms/classrooms.controller.ts @@ -223,4 +223,36 @@ export class ClassroomsController { }); return result; } -} + + @Get('report') + @RequirePermission('classroom:view') + async exportReport( + @Query('dateFrom') dateFrom: string, + @Query('dateTo') dateTo: string, + @Res() res: Response, + ) { + const data = await this.service.getUsageReport(dateFrom, dateTo); + const workbook = new ExcelJS.Workbook(); + const ws = workbook.addWorksheet('教室使用统计'); + ws.columns = [ + { header: '教室名称', key: 'name', width: 20 }, + { header: '楼栋', key: 'building', width: 12 }, + { header: '类型', key: 'roomType', width: 12 }, + { header: '容量', key: 'capacity', width: 8 }, + { header: '统计天数', key: 'totalDays', width: 10 }, + { header: '租赁占用天数', key: 'rentalDays', width: 14 }, + { header: '排课占用天数', key: 'scheduleDays', width: 14 }, + { header: '空闲天数', key: 'idleDays', width: 10 }, + { header: '占用率', key: 'occupancyRate', width: 10 }, + ]; + ws.getRow(1).font = { bold: true }; + ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } }; + for (const row of data) { + ws.addRow({ ...row, occupancyRate: `${row.occupancyRate}%` }); + } + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.setHeader('Content-Disposition', 'attachment; filename=classroom-report.xlsx'); + await workbook.xlsx.write(res); + res.end(); + } + } diff --git a/apps/server/src/classrooms/classrooms.module.ts b/apps/server/src/classrooms/classrooms.module.ts index c699357..c01f36e 100644 --- a/apps/server/src/classrooms/classrooms.module.ts +++ b/apps/server/src/classrooms/classrooms.module.ts @@ -2,12 +2,14 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Classroom } from '../entities/classroom.entity'; import { ClassroomRental } from '../entities/classroom-rental.entity'; +import { ClassSchedule } from '../entities/class-schedule.entity'; import { ClassroomsService } from './classrooms.service'; import { ClassroomsController } from './classrooms.controller'; import { OperationLogsModule } from '../operation-logs/operation-logs.module'; +import { DepartmentsModule } from '../departments/departments.module'; @Module({ - imports: [TypeOrmModule.forFeature([Classroom, ClassroomRental]), OperationLogsModule], + imports: [TypeOrmModule.forFeature([Classroom, ClassroomRental, ClassSchedule]), OperationLogsModule, DepartmentsModule], controllers: [ClassroomsController], providers: [ClassroomsService], exports: [ClassroomsService], diff --git a/apps/server/src/classrooms/classrooms.service.ts b/apps/server/src/classrooms/classrooms.service.ts index 27a075e..bdc2a4d 100644 --- a/apps/server/src/classrooms/classrooms.service.ts +++ b/apps/server/src/classrooms/classrooms.service.ts @@ -3,21 +3,26 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository, Not } from 'typeorm'; import { Classroom } from '../entities/classroom.entity'; import { ClassroomRental } from '../entities/classroom-rental.entity'; +import { ClassSchedule } from '../entities/class-schedule.entity'; +import { CampusScope } from '../common/campus-scope'; import { CreateClassroomDto, UpdateClassroomDto } from './dto/classroom.dto'; @Injectable() export class ClassroomsService { + constructor( @InjectRepository(Classroom) private repo: Repository, @InjectRepository(ClassroomRental) private rentalRepo: Repository, + @InjectRepository(ClassSchedule) private scheduleRepo: Repository, + private readonly scope: CampusScope, ) {} async findAll(query?: { building?: string; roomType?: string; includeArchived?: boolean }) { - const where: any = {}; + const where: Record = {}; if (query?.building) where.building = query.building; if (query?.roomType) where.roomType = query.roomType; if (!query?.includeArchived) where.status = Not('archived'); - return this.repo.find({ where, order: { building: 'ASC', name: 'ASC' } }); + return this.repo.find({ where: await this.scope.filter(where), order: { building: 'ASC', name: 'ASC' } }); } async findOne(id: number) { @@ -40,18 +45,14 @@ export class ClassroomsService { async remove(id: number) { await this.findOne(id); - // 若存在未结束的租赁订单,不允许归档 - const active = await this.rentalRepo.count({ where: { classroomId: id, status: 'active' } }); - if (active > 0) throw new BadRequestException('该教室存在进行中的租赁订单,无法归档'); - await this.repo.update(id, { status: 'archived' }); + await this.repo.update(id, { status: 'archived' } as any); return { message: '已归档' }; } async restore(id: number) { - const cls = await this.findOne(id); - if (cls.status !== 'archived') throw new BadRequestException('该教室未被归档'); - await this.repo.update(id, { status: 'available' }); - return { message: '已恢复' }; + await this.findOne(id); + await this.repo.update(id, { status: 'available' } as any); + return this.repo.findOne({ where: { id } }); } async batchImport( @@ -62,39 +63,82 @@ export class ClassroomsService { capacity?: number; roomType?: string; courseType?: string; - supervisor?: string; }[], ) { let imported = 0; let skipped = 0; + const errors: string[] = []; for (const row of rows) { - if (!row.name || !row.name.trim()) { - skipped++; - continue; - } - const name = row.name.trim(); - const exists = await this.repo.findOne({ where: { name } }); - if (exists) { - skipped++; - continue; - } - await this.repo.save( - this.repo.create({ - name, - building: row.building?.trim() || undefined, - floor: row.floor || undefined, - capacity: row.capacity || 30, - roomType: row.roomType?.trim() || '大', - courseType: row.courseType?.trim() || undefined, - supervisor: row.supervisor?.trim() || undefined, - }), - ); + if (!row.name?.trim()) { skipped++; continue; } + const exists = await this.repo.findOne({ where: { name: row.name.trim() } }); + if (exists) { errors.push(`教室 ${row.name} 已存在`); skipped++; continue; } + await this.repo.save(this.repo.create({ ...row, capacity: row.capacity || 30 })); imported++; } - return { - message: `成功导入 ${imported} 间教室,跳过 ${skipped} 条(重复或空行)`, - imported, - skipped, - }; + return { message: `成功导入 ${imported} 间教室,跳过 ${skipped} 间`, imported, skipped, errors: errors.length > 0 ? errors : undefined }; + } + + async getUsageReport(dateFrom: string, dateTo: string) { + const classrooms = await this.repo.find({ + where: { status: Not('archived') }, + order: { building: 'ASC', name: 'ASC' }, + }); + + const rentals = await this.rentalRepo + .createQueryBuilder('r') + .where('r.status != :cancelled', { cancelled: 'cancelled' }) + .andWhere('r.startDate <= :dateTo AND r.endDate >= :dateFrom', { dateFrom, dateTo }) + .getMany(); + + const schedules = await this.scheduleRepo + .createQueryBuilder('s') + .where('s.status = :active', { active: 'active' }) + .andWhere('s.scheduleType = :type', { type: 'INTERNAL' }) + .andWhere('s.startDate <= :dateTo AND s.endDate >= :dateFrom', { dateFrom, dateTo }) + .getMany(); + + const start = new Date(dateFrom); + const end = new Date(dateTo); + const totalDays = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1; + + const rentalDaysByRoom: Record> = {}; + const scheduleDaysByRoom: Record> = {}; + + for (const r of rentals) { + if (!rentalDaysByRoom[r.classroomId]) rentalDaysByRoom[r.classroomId] = new Set(); + const effStart = new Date(Math.max(new Date(r.startDate).getTime(), start.getTime())); + const effEnd = new Date(Math.min(new Date(r.endDate).getTime(), end.getTime())); + for (let d = new Date(effStart); d <= effEnd; d.setDate(d.getDate() + 1)) { + rentalDaysByRoom[r.classroomId].add(d.toISOString().slice(0, 10)); + } + } + + for (const s of schedules) { + if (!scheduleDaysByRoom[s.classroomId]) scheduleDaysByRoom[s.classroomId] = new Set(); + const effStart = new Date(Math.max(new Date(s.startDate).getTime(), start.getTime())); + const effEnd = new Date(Math.min(new Date(s.endDate).getTime(), end.getTime())); + for (let d = new Date(effStart); d <= effEnd; d.setDate(d.getDate() + 1)) { + scheduleDaysByRoom[s.classroomId].add(d.toISOString().slice(0, 10)); + } + } + + return classrooms.map((c) => { + const rentalDays = rentalDaysByRoom[c.id]?.size || 0; + const scheduleDays = scheduleDaysByRoom[c.id]?.size || 0; + const usedDays = rentalDays + scheduleDays; + return { + id: c.id, + name: c.name, + building: c.building || '', + roomType: c.roomType || '', + capacity: c.capacity, + totalDays, + rentalDays, + scheduleDays, + usedDays, + idleDays: totalDays - usedDays, + occupancyRate: totalDays > 0 ? ((usedDays / totalDays) * 100).toFixed(1) : '0.0', + }; + }); } } diff --git a/apps/server/src/common/campus-scope.ts b/apps/server/src/common/campus-scope.ts index fc3616e..3b6e494 100644 --- a/apps/server/src/common/campus-scope.ts +++ b/apps/server/src/common/campus-scope.ts @@ -49,6 +49,13 @@ export class CampusScope { return { ...where, departmentId: In(ids) } as unknown as T; } + /** Returns department IDs for QueryBuilder .andWhere() usage. null = no filtering needed. */ + async getScopeDepartmentIds(): Promise { + if (this.isSuperAdmin && !this.currentDepartmentId) return null; + const ids = await this.getEffectiveScopeIds(); + return ids.length > 0 ? ids : null; + } + private async getEffectiveScopeIds(): Promise { // Specific campus selected → campus + descendants if (this.currentDepartmentId) { diff --git a/apps/server/src/dashboard/dashboard.controller.ts b/apps/server/src/dashboard/dashboard.controller.ts index ec6fd0b..55fba8e 100644 --- a/apps/server/src/dashboard/dashboard.controller.ts +++ b/apps/server/src/dashboard/dashboard.controller.ts @@ -38,4 +38,14 @@ export class DashboardController { ) { return this.service.getRoomExpenseRanking(periodStart, periodEnd); } -} + + @Get('class-attendance-ranking') + getClassAttendanceRanking() { + return this.service.getClassAttendanceRanking(); + } + + @Get('classroom-occupancy') + getClassroomOccupancy() { + return this.service.getClassroomOccupancy(); + } + } diff --git a/apps/server/src/dashboard/dashboard.module.ts b/apps/server/src/dashboard/dashboard.module.ts index b864386..1760794 100644 --- a/apps/server/src/dashboard/dashboard.module.ts +++ b/apps/server/src/dashboard/dashboard.module.ts @@ -13,10 +13,11 @@ import { Deposit } from '../entities/deposit.entity'; import { ClassroomRental } from '../entities/classroom-rental.entity'; import { ClassTeacher } from '../entities/class-teacher.entity'; import { DashboardService } from './dashboard.service'; +import { DepartmentsModule } from '../departments/departments.module'; 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]), DepartmentsModule], controllers: [DashboardController], providers: [DashboardService], }) diff --git a/apps/server/src/dashboard/dashboard.service.ts b/apps/server/src/dashboard/dashboard.service.ts index 7e9d69b..051bd35 100644 --- a/apps/server/src/dashboard/dashboard.service.ts +++ b/apps/server/src/dashboard/dashboard.service.ts @@ -13,9 +13,11 @@ 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 { CampusScope } from '../common/campus-scope'; @Injectable() export class DashboardService { + constructor( @InjectRepository(Room) private roomRepo: Repository, @InjectRepository(Student) private studentRepo: Repository, @@ -29,54 +31,60 @@ export class DashboardService { @InjectRepository(Deposit) private depositRepo: Repository, @InjectRepository(ClassroomRental) private rentalRepo: Repository, @InjectRepository(ClassTeacher) private classTeacherRepo: Repository, + private readonly scope: CampusScope, ) {} async getStats() { const today = new Date(); const todayStr = today.toISOString().slice(0, 10); const currentMonth = todayStr.slice(0, 7); // YYYY-MM + const scopeIds = await this.scope.getScopeDepartmentIds(); - const totalRooms = await this.roomRepo.count({ where: { status: Not('archived') } }); - const totalStudents = await this.studentRepo.count({ where: { status: 'active' } }); - const occupiedBeds = await this.occRepo.count({ where: { checkOutDate: IsNull() } }); - const totalCapacity = await this.roomRepo + const totalRooms = await this.roomRepo.count({ where: await this.scope.filter({ status: Not('archived') }) }); + const totalStudents = await this.studentRepo.count({ where: await this.scope.filter({ status: 'active' }) }); + const occupiedBeds = await this.occRepo.count({ where: await this.scope.filter({ checkOutDate: IsNull() }) }); + const capQb = this.roomRepo .createQueryBuilder('r') .select('SUM(r.capacity)', 'total') - .where('r.status != :archived', { archived: 'archived' }) - .getRawOne(); + .where('r.status != :archived', { archived: 'archived' }); + if (scopeIds) capQb.andWhere('r.departmentId IN (:...scopeIds)', { scopeIds }); + const totalCapacity = await capQb.getRawOne(); const cap = totalCapacity?.total || 0; const occupancyRate = cap > 0 ? ((occupiedBeds / cap) * 100).toFixed(1) : 0; - const billStats = await this.billRepo + const billStatsQb = this.billRepo .createQueryBuilder('b') .select('b.status', 'status') .addSelect('COUNT(*)', 'count') .addSelect('SUM(b.totalAmount)', 'total') - .groupBy('b.status') - .getRawMany(); + .groupBy('b.status'); + if (scopeIds) billStatsQb.andWhere('b.departmentId IN (:...scopeIds)', { scopeIds }); + const billStats = await billStatsQb.getRawMany(); // New fields - const classroomCount = await this.classroomRepo.count(); + const classroomCount = await this.classroomRepo.count({ where: await this.scope.filter({}) }); - const occResult = await this.scheduleRepo + const occQb = this.scheduleRepo .createQueryBuilder('s') .select('COUNT(DISTINCT s.classroomId)', 'cnt') .where('s.status = :active', { active: 'active' }) .andWhere('s.startDate <= :today', { today: todayStr }) - .andWhere('s.endDate >= :today', { today: todayStr }) - .getRawOne(); + .andWhere('s.endDate >= :today', { today: todayStr }); + if (scopeIds) occQb.andWhere('s.departmentId IN (:...scopeIds)', { scopeIds }); + const occResult = await occQb.getRawOne(); const occupiedClassrooms = parseInt(occResult?.cnt || '0', 10); const classroomOccupancyRate = classroomCount > 0 ? ((occupiedClassrooms / classroomCount) * 100).toFixed(1) : 0; - const attTodayStats = await this.attendanceRepo + const attTodayQb = this.attendanceRepo .createQueryBuilder('a') .select('a.status', 'status') .addSelect('COUNT(*)', 'count') .where('a.attendanceDate = :today', { today: todayStr }) - .groupBy('a.status') - .getRawMany(); + .groupBy('a.status'); + if (scopeIds) attTodayQb.andWhere('a.departmentId IN (:...scopeIds)', { scopeIds }); + 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') @@ -85,59 +93,62 @@ export class DashboardService { ? ((todayPresent / todayTotal) * 100).toFixed(1) : 0; - const incomeResult = await this.billRepo + const incomeQb = this.billRepo .createQueryBuilder('b') .select('SUM(b.totalAmount)', 'total') .where('b.status = :paid', { paid: 'paid' }) .andWhere('b.periodStart >= :start', { start: `${currentMonth}-01` }) - .andWhere('b.periodStart < :end', { end: this.nextMonth(currentMonth) }) - .getRawOne(); + .andWhere('b.periodStart < :end', { end: this.nextMonth(currentMonth) }); + if (scopeIds) incomeQb.andWhere('b.departmentId IN (:...scopeIds)', { scopeIds }); + const incomeResult = await incomeQb.getRawOne(); const monthlyIncome = parseFloat(incomeResult?.total || '0'); const attendanceTrend = await this.getAttendanceTrend(todayStr); const incomeTrend = await this.getIncomeTrend(currentMonth); // --- New stats --- - const classCount = await this.classRepo.count(); + const classCount = await this.classRepo.count({ where: await this.scope.filter({}) }); + // classTeacherRepo does not have departmentId — skip scope filtering const teacherResult = await this.classTeacherRepo .createQueryBuilder('ct') .select('COUNT(DISTINCT ct.userId)', 'cnt') .getRawOne(); const teacherCount = parseInt(teacherResult?.cnt || '0', 10); - const pendingResult = await this.depositRepo + const pendingQb = this.depositRepo .createQueryBuilder('d') .select('SUM(d.amount)', 'total') .where('d.status = :paid', { paid: 'paid' }) - .andWhere('d.refundStatus IS NULL') - .getRawOne(); + .andWhere('d.refundStatus IS NULL'); + if (scopeIds) pendingQb.andWhere('d.departmentId IN (:...scopeIds)', { scopeIds }); + 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: await this.scope.filter({ endDate: MoreThanOrEqual(todayStr) }) }); - const occupancyByBuilding = await this.occRepo + const occByBldQb = this.occRepo .createQueryBuilder('o') .leftJoin('o.room', 'r') .select('r.building', 'building') .addSelect('COUNT(*)', 'count') - .where('o.checkOutDate IS NULL') - .groupBy('r.building') - .getRawMany(); + .where('o.checkOutDate IS NULL'); + if (scopeIds) occByBldQb.andWhere('r.departmentId IN (:...scopeIds)', { scopeIds }); + 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); - const expenseByType = await this.expRepo + const expByTypeQb = this.expRepo .createQueryBuilder('e') .select('e.expenseType', 'type') .addSelect('SUM(e.amount)', 'total') .where('e.periodStart >= :start', { start: `${currentMonth}-01` }) - .andWhere('e.periodEnd <= :end', { end: this.nextMonth(currentMonth) }) - .groupBy('e.expenseType') - .getRawMany(); + .andWhere('e.periodEnd <= :end', { end: this.nextMonth(currentMonth) }); + if (scopeIds) expByTypeQb.andWhere('e.departmentId IN (:...scopeIds)', { scopeIds }); + const expenseByType = await expByTypeQb.groupBy('e.expenseType').getRawMany(); return { totalRooms, @@ -227,6 +238,7 @@ export class DashboardService { // 甘特图数据:每个宿舍的入住时间线 async getGanttData(query?: { periodStart?: string; periodEnd?: string; building?: string }) { + const scopeIds = await this.scope.getScopeDepartmentIds(); const qb = this.occRepo .createQueryBuilder('o') .leftJoinAndSelect('o.student', 'student') @@ -235,6 +247,8 @@ export class DashboardService { .orderBy('room.roomNumber', 'ASC') .addOrderBy('o.checkInDate', 'ASC'); + if (scopeIds) qb.andWhere('room.departmentId IN (:...scopeIds)', { scopeIds }); + if (query?.building) { qb.andWhere('room.building = :building', { building: query.building }); } @@ -248,7 +262,7 @@ export class DashboardService { const records = await qb.getMany(); // 按宿舍分组 - const roomMap = new Map(); + const roomMap = new Map[]>(); for (const r of records) { const key = r.room?.roomNumber || String(r.roomId); if (!roomMap.has(key)) roomMap.set(key, []); @@ -267,14 +281,15 @@ export class DashboardService { occupancies, })); } - // 费用统计 async getExpenseStats(periodStart?: string, periodEnd?: string) { + const scopeIds = await this.scope.getScopeDepartmentIds(); const qb = this.expRepo .createQueryBuilder('e') .select('e.expenseType', 'type') .addSelect('SUM(e.amount)', 'total') .groupBy('e.expenseType'); + if (scopeIds) qb.andWhere('e.departmentId IN (:...scopeIds)', { scopeIds }); if (periodStart) qb.andWhere('e.periodStart >= :ps', { ps: periodStart }); if (periodEnd) qb.andWhere('e.periodEnd <= :pe', { pe: periodEnd }); return qb.getRawMany(); @@ -282,6 +297,7 @@ export class DashboardService { // 各宿舍费用排行 async getRoomExpenseRanking(periodStart?: string, periodEnd?: string) { + const scopeIds = await this.scope.getScopeDepartmentIds(); const qb = this.expRepo .createQueryBuilder('e') .leftJoin('e.room', 'room') @@ -291,8 +307,80 @@ export class DashboardService { .groupBy('e.roomId') .orderBy('total', 'DESC') .limit(20); + if (scopeIds) qb.andWhere('e.departmentId IN (:...scopeIds)', { scopeIds }); if (periodStart) qb.andWhere('e.periodStart >= :ps', { ps: periodStart }); if (periodEnd) qb.andWhere('e.periodEnd <= :pe', { pe: periodEnd }); return qb.getRawMany(); } -} + + // 班级考勤排行 + async getClassAttendanceRanking() { + const scopeIds = await this.scope.getScopeDepartmentIds(); + 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'); + if (scopeIds) qb.andWhere('a.departmentId IN (:...scopeIds)', { scopeIds }); + const raw = await qb.getRawMany(); + + const classMap = new Map(); + 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 }); + const entry = classMap.get(Number(r.classId))!; + const n = parseInt(r.count, 10); + entry.total += n; + if (r.status === 'present') entry.present += n; + } + + const ranked = Array.from(classMap.values()) + .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() }; + } + + async getClassroomOccupancy() { + const scopeIds = await this.scope.getScopeDepartmentIds(); + const classrooms = await this.classroomRepo.find({ + where: await this.scope.filter({ status: Not('archived') }), + order: { building: 'ASC', name: 'ASC' }, + }); + const today = new Date().toISOString().slice(0, 10); + const schedQb = this.scheduleRepo + .createQueryBuilder('s') + .select('s.classroomId', 'classroomId') + .addSelect('COUNT(DISTINCT s.weekDay)', 'weekDays') + .where('s.status = :active', { active: 'active' }) + .andWhere('s.scheduleType = :type', { type: 'INTERNAL' }) + .andWhere('s.startDate <= :today AND s.endDate >= :today', { today }) + .groupBy('s.classroomId'); + if (scopeIds) schedQb.andWhere('s.departmentId IN (:...scopeIds)', { scopeIds }); + const schedules = await schedQb.getRawMany(); + const rentalQb = this.rentalRepo + .createQueryBuilder('r') + .select('r.classroomId', 'classroomId') + .addSelect('COUNT(*)', 'rentalCount') + .where('r.status != :cancelled', { cancelled: 'cancelled' }) + .andWhere('r.startDate <= :today AND r.endDate >= :today', { today }) + .groupBy('r.classroomId'); + if (scopeIds) rentalQb.andWhere('r.departmentId IN (:...scopeIds)', { scopeIds }); + const rentals = await rentalQb.getRawMany(); + const sMap: Record = {}; + const rMap: Record = {}; + for (const s of schedules) sMap[s.classroomId] = parseInt(s.weekDays, 10); + for (const r of rentals) rMap[r.classroomId] = parseInt(r.rentalCount, 10); + return classrooms.map((c) => ({ + name: c.name, + building: c.building || '', + capacity: c.capacity, + scheduleDays: sMap[c.id] || 0, + rentalCount: rMap[c.id] || 0, + occupancy: Math.min(((sMap[c.id] || 0) + (rMap[c.id] || 0) * 3) / 7, 1), + })); + } + } diff --git a/apps/server/src/deposits/deposits.module.ts b/apps/server/src/deposits/deposits.module.ts index 5c49211..edecac5 100644 --- a/apps/server/src/deposits/deposits.module.ts +++ b/apps/server/src/deposits/deposits.module.ts @@ -4,12 +4,13 @@ import { Student } from '../entities/student.entity'; import { Deposit } from '../entities/deposit.entity'; import { DepositInstallment } from '../entities/deposit-installment.entity'; import { DepositsService } from './deposits.service'; +import { DepartmentsModule } from '../departments/departments.module'; import { DepositsController } from './deposits.controller'; import { OperationLogsModule } from '../operation-logs/operation-logs.module'; import { NotificationsModule } from '../notifications/notifications.module'; @Module({ - imports: [TypeOrmModule.forFeature([Deposit, DepositInstallment, Student]), OperationLogsModule, NotificationsModule], + imports: [TypeOrmModule.forFeature([Deposit, DepositInstallment, Student]), OperationLogsModule, NotificationsModule, DepartmentsModule], controllers: [DepositsController], providers: [DepositsService], exports: [DepositsService], diff --git a/apps/server/src/deposits/deposits.service.ts b/apps/server/src/deposits/deposits.service.ts index d513466..692c572 100644 --- a/apps/server/src/deposits/deposits.service.ts +++ b/apps/server/src/deposits/deposits.service.ts @@ -3,22 +3,27 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Deposit } from '../entities/deposit.entity'; import { DepositInstallment } from '../entities/deposit-installment.entity'; +import { CampusScope } from '../common/campus-scope'; import { CreateDepositDto, RefundDepositDto, CreateDepositWithInstallmentsDto } from './dto/deposit.dto'; @Injectable() export class DepositsService { + constructor( @InjectRepository(Deposit) private repo: Repository, @InjectRepository(DepositInstallment) private installmentRepo: Repository, + private readonly scope: CampusScope, ) {} async findAll(query?: { studentId?: number; status?: string }) { + const scopeIds = await this.scope.getScopeDepartmentIds(); const qb = this.repo .createQueryBuilder('d') .leftJoinAndSelect('d.student', 'student') .leftJoinAndSelect('d.installments', 'installments') .orderBy('d.createdAt', 'DESC'); + if (scopeIds) qb.andWhere('d.departmentId IN (:...scopeIds)', { scopeIds }); if (query?.studentId) qb.andWhere('d.studentId = :studentId', { studentId: query.studentId }); if (query?.status) qb.andWhere('d.status = :status', { status: query.status }); return qb.getMany(); @@ -145,12 +150,12 @@ export class DepositsService { return this.repo.save(deposit); } - async findPendingRefunds() { + const baseWhere = await this.scope.filter({}); return this.repo.find({ where: [ - { refundStatus: 'pending' }, - { refundStatus: 'head_teacher_approved' }, + { ...baseWhere, refundStatus: 'pending' }, + { ...baseWhere, refundStatus: 'head_teacher_approved' }, ], relations: ['student', 'installments'], order: { refundRequestedAt: 'DESC' }, @@ -165,13 +170,14 @@ export class DepositsService { } async getStats() { - const result = await this.repo + const scopeIds = await this.scope.getScopeDepartmentIds(); + const qb = this.repo .createQueryBuilder('d') .select('d.status', 'status') .addSelect('COUNT(*)', 'count') - .addSelect('SUM(d.amount)', 'totalAmount') - .groupBy('d.status') - .getRawMany(); - return result; + .addSelect('SUM(d.amount)', 'totalAmount'); + if (scopeIds) qb.andWhere('d.departmentId IN (:...scopeIds)', { scopeIds }); + qb.groupBy('d.status'); + return qb.getRawMany(); } } diff --git a/apps/server/src/expense-types/dto/expense-type.dto.ts b/apps/server/src/expense-types/dto/expense-type.dto.ts new file mode 100644 index 0000000..fba2173 --- /dev/null +++ b/apps/server/src/expense-types/dto/expense-type.dto.ts @@ -0,0 +1,35 @@ +import { IsString, IsOptional, IsInt, IsBoolean, IsIn } from 'class-validator'; + +export class CreateExpenseTypeDto { + @IsString() + code: string; + + @IsString() + name: string; + + @IsOptional() + @IsIn(['room', 'personal', 'both']) + category?: string; + + @IsOptional() + @IsInt() + sortOrder?: number; +} + +export class UpdateExpenseTypeDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsIn(['room', 'personal', 'both']) + category?: string; + + @IsOptional() + @IsInt() + sortOrder?: number; + + @IsOptional() + @IsBoolean() + enabled?: boolean; +} diff --git a/apps/server/src/expense-types/expense-types.controller.ts b/apps/server/src/expense-types/expense-types.controller.ts new file mode 100644 index 0000000..5de8fd9 --- /dev/null +++ b/apps/server/src/expense-types/expense-types.controller.ts @@ -0,0 +1,84 @@ +import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards, Request } from '@nestjs/common'; +import { ExpenseTypesService } from './expense-types.service'; +import { CreateExpenseTypeDto, UpdateExpenseTypeDto } from './dto/expense-type.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('expense-types') +export class ExpenseTypesController { + constructor( + private readonly service: ExpenseTypesService, + private readonly logService: OperationLogsService, + ) {} + + @Get() + @RequirePermission('expense:view') + findAll() { + return this.service.findAll(); + } + + @Get('admin') + @RequirePermission('expense:edit') + findAllAdmin() { + return this.service.findAllAdmin(); + } + + @Post() + @RequirePermission('expense:create') + async create(@Body() dto: CreateExpenseTypeDto, @Request() req: any) { + const { ipAddress, userAgent } = extractRequestInfo(req); + const result = await this.service.create(dto); + await this.logService.log({ + userId: req.user?.id, + username: req.user?.username, + module: '费用类型', + action: '新增费用类型', + targetId: result.id, + targetType: 'expense_type', + detail: `${dto.code} - ${dto.name}`, + ipAddress, + userAgent, + }); + return result; + } + + @Put(':id') + @RequirePermission('expense:edit') + async update(@Param('id') id: string, @Body() dto: UpdateExpenseTypeDto, @Request() req: any) { + const { ipAddress, userAgent } = extractRequestInfo(req); + const result = await this.service.update(+id, dto); + await this.logService.log({ + userId: req.user?.id, + username: req.user?.username, + module: '费用类型', + action: '编辑费用类型', + targetId: +id, + targetType: 'expense_type', + detail: JSON.stringify(dto), + ipAddress, + userAgent, + }); + return result; + } + + @Delete(':id') + @RequirePermission('expense:delete') + async remove(@Param('id') id: string, @Request() req: any) { + const { ipAddress, userAgent } = extractRequestInfo(req); + await this.service.remove(+id); + await this.logService.log({ + userId: req.user?.id, + username: req.user?.username, + module: '费用类型', + action: '删除费用类型', + targetId: +id, + targetType: 'expense_type', + ipAddress, + userAgent, + }); + return { message: '已删除' }; + } +} diff --git a/apps/server/src/expense-types/expense-types.module.ts b/apps/server/src/expense-types/expense-types.module.ts new file mode 100644 index 0000000..bdf3309 --- /dev/null +++ b/apps/server/src/expense-types/expense-types.module.ts @@ -0,0 +1,20 @@ +import { Module, OnModuleInit } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ExpenseType } from '../entities/expense-type.entity'; +import { ExpenseTypesService } from './expense-types.service'; +import { ExpenseTypesController } from './expense-types.controller'; +import { OperationLogsModule } from '../operation-logs/operation-logs.module'; + +@Module({ + imports: [TypeOrmModule.forFeature([ExpenseType]), OperationLogsModule], + controllers: [ExpenseTypesController], + providers: [ExpenseTypesService], + exports: [ExpenseTypesService], +}) +export class ExpenseTypesModule implements OnModuleInit { + constructor(private readonly service: ExpenseTypesService) {} + + async onModuleInit() { + await this.service.seedDefaults(); + } +} diff --git a/apps/server/src/expense-types/expense-types.service.ts b/apps/server/src/expense-types/expense-types.service.ts new file mode 100644 index 0000000..0cbfe46 --- /dev/null +++ b/apps/server/src/expense-types/expense-types.service.ts @@ -0,0 +1,70 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ExpenseType } from '../entities/expense-type.entity'; +import { CreateExpenseTypeDto, UpdateExpenseTypeDto } from './dto/expense-type.dto'; + +const DEFAULT_TYPES = [ + { code: 'water', name: '水费', category: 'room', sortOrder: 1 }, + { code: 'electricity', name: '电费', category: 'room', sortOrder: 2 }, + { code: 'cleaning', name: '保洁费', category: 'room', sortOrder: 3 }, + { code: 'damage', name: '损坏赔偿', category: 'both', sortOrder: 4 }, + { code: 'penalty', name: '罚款', category: 'personal', sortOrder: 5 }, + { code: 'key', name: '钥匙费', category: 'personal', sortOrder: 6 }, + { code: 'remote', name: '空调遥控器', category: 'personal', sortOrder: 7 }, + { code: 'deposit_deduction', name: '押金扣除', category: 'personal', sortOrder: 8 }, + { code: 'other', name: '其他', category: 'both', sortOrder: 99 }, +]; + +@Injectable() +export class ExpenseTypesService { + constructor( + @InjectRepository(ExpenseType) + private repo: Repository, + ) {} + + async seedDefaults(): Promise { + for (const t of DEFAULT_TYPES) { + const exists = await this.repo.findOne({ where: { code: t.code } }); + if (!exists) { + await this.repo.save(this.repo.create(t)); + } + } + } + + async findAll(): Promise { + return this.repo.find({ where: { enabled: true }, order: { sortOrder: 'ASC' } }); + } + + async findAllAdmin(): Promise { + return this.repo.find({ order: { sortOrder: 'ASC' } }); + } + + async findOne(id: number): Promise { + const t = await this.repo.findOne({ where: { id } }); + if (!t) throw new NotFoundException('费用类型不存在'); + return t; + } + + async getValidCodes(): Promise { + const types = await this.findAll(); + return types.map((t) => t.code); + } + + async create(dto: CreateExpenseTypeDto): Promise { + const exists = await this.repo.findOne({ where: { code: dto.code } }); + if (exists) throw new ConflictException('费用类型代码已存在'); + return this.repo.save(this.repo.create(dto)); + } + + async update(id: number, dto: UpdateExpenseTypeDto): Promise { + const t = await this.findOne(id); + Object.assign(t, dto); + return this.repo.save(t); + } + + async remove(id: number): Promise { + const t = await this.findOne(id); + await this.repo.remove(t); + } +} diff --git a/apps/server/src/expenses/dto/expense.dto.ts b/apps/server/src/expenses/dto/expense.dto.ts index ac2cdea..b902947 100644 --- a/apps/server/src/expenses/dto/expense.dto.ts +++ b/apps/server/src/expenses/dto/expense.dto.ts @@ -1,10 +1,10 @@ -import { IsInt, IsString, IsNumber, IsOptional, IsEnum } from 'class-validator'; +import { IsInt, IsString, IsNumber, IsOptional } from 'class-validator'; export class CreateRoomExpenseDto { @IsInt() roomId: number; - @IsEnum(['water', 'electricity', 'cleaning', 'damage', 'other']) + @IsString() expenseType: string; @IsNumber() @@ -29,7 +29,7 @@ export class CreatePersonalExpenseDto { @IsInt() roomId?: number; - @IsEnum(['damage', 'cleaning', 'penalty', 'key', 'remote', 'deposit_deduction', 'other']) + @IsString() expenseType: string; @IsNumber() diff --git a/apps/server/src/expenses/expenses.controller.ts b/apps/server/src/expenses/expenses.controller.ts index b6929a4..93d12d5 100644 --- a/apps/server/src/expenses/expenses.controller.ts +++ b/apps/server/src/expenses/expenses.controller.ts @@ -405,15 +405,6 @@ export class ExpensesController { @RequirePermission('expense:view') async exportPersonalExpenses(@Res() res: Response) { const data = await this.service.findPersonalExpenses(); - const typeMap: Record = { - damage: '物品损坏', - cleaning: '保洁费', - penalty: '罚款', - key: '钥匙费', - remote: '空调遥控器', - deposit_deduction: '押金扣除', - other: '其他', - }; const workbook = new ExcelJS.Workbook(); const ws = workbook.addWorksheet('个人附加费'); ws.columns = [ @@ -427,7 +418,7 @@ export class ExpensesController { data.forEach((d: any) => { ws.addRow({ studentName: d.student?.name || '', - expenseType: typeMap[d.expenseType] || d.expenseType, + expenseType: d.expenseType, amount: Number(d.amount), expenseDate: d.expenseDate, description: d.description || '', diff --git a/apps/server/src/expenses/expenses.module.ts b/apps/server/src/expenses/expenses.module.ts index 2a71e90..d8fa3ab 100644 --- a/apps/server/src/expenses/expenses.module.ts +++ b/apps/server/src/expenses/expenses.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { DepartmentsModule } from '../departments/departments.module'; import { RoomExpense } from '../entities/room-expense.entity'; import { PersonalExpense } from '../entities/personal-expense.entity'; import { Room } from '../entities/room.entity'; @@ -12,6 +13,7 @@ import { OperationLogsModule } from '../operation-logs/operation-logs.module'; imports: [ TypeOrmModule.forFeature([RoomExpense, PersonalExpense, Room, Student]), OperationLogsModule, + DepartmentsModule, ], controllers: [ExpensesController], providers: [ExpensesService], diff --git a/apps/server/src/expenses/expenses.service.ts b/apps/server/src/expenses/expenses.service.ts index 79cfa40..6fc6916 100644 --- a/apps/server/src/expenses/expenses.service.ts +++ b/apps/server/src/expenses/expenses.service.ts @@ -11,6 +11,7 @@ import { BatchRoomExpenseDto, } from './dto/expense.dto'; import { RoomsService } from '../rooms/rooms.service'; +import { CampusScope } from '../common/campus-scope'; @Injectable() export class ExpensesService { @@ -19,6 +20,7 @@ export class ExpensesService { @InjectRepository(PersonalExpense) private personalExpRepo: Repository, @InjectRepository(Room) private roomRepo: Repository, @InjectRepository(Student) private studentRepo: Repository, + private readonly scope: CampusScope, ) {} // 宿舍费用 @@ -42,10 +44,12 @@ export class ExpensesService { } async findRoomExpenses(query?: { roomId?: number; periodStart?: string; periodEnd?: string }) { + const scopeIds = await this.scope.getScopeDepartmentIds(); const qb = this.roomExpRepo .createQueryBuilder('e') .leftJoinAndSelect('e.room', 'room') .orderBy('e.createdAt', 'DESC'); + if (scopeIds) qb.andWhere('e.departmentId IN (:...scopeIds)', { scopeIds }); if (query?.roomId) qb.andWhere('e.roomId = :roomId', { roomId: query.roomId }); if (query?.periodStart) qb.andWhere('e.periodStart >= :ps', { ps: query.periodStart }); if (query?.periodEnd) qb.andWhere('e.periodEnd <= :pe', { pe: query.periodEnd }); @@ -82,8 +86,9 @@ export class ExpensesService { } async findPersonalExpenses(query?: { studentId?: number }) { - const where: any = {}; + let where: Record = {}; if (query?.studentId) where.studentId = query.studentId; + where = await this.scope.filter(where); return this.personalExpRepo.find({ where, relations: ['student'], @@ -286,19 +291,6 @@ export class ExpensesService { let skipped = 0; const errors: string[] = []; - const typeMap: Record = { - 物品损坏: 'damage', - 损坏: 'damage', - 保洁费: 'cleaning', - 保洁: 'cleaning', - 罚款: 'penalty', - 钥匙费: 'key', - 钥匙: 'key', - 空调遥控器: 'remote', - 遥控器: 'remote', - 押金扣除: 'deposit_deduction', - 其他: 'other', - }; for (let i = 0; i < rows.length; i++) { const row = rows[i]; @@ -319,21 +311,9 @@ export class ExpensesService { } // 解析费用类型 - let expenseType = row.expenseType?.trim() || ''; - if (typeMap[expenseType]) { - expenseType = typeMap[expenseType]; - } - const validTypes = [ - 'damage', - 'cleaning', - 'penalty', - 'key', - 'remote', - 'deposit_deduction', - 'other', - ]; - if (!validTypes.includes(expenseType)) { - errors.push(`第${rowNum}行: 费用类型"${row.expenseType}"无效`); + const expenseType = row.expenseType?.trim() || ''; + if (!expenseType) { + errors.push(`第${rowNum}行: 费用类型不能为空`); skipped++; continue; } diff --git a/apps/server/src/occupancies/occupancies.module.ts b/apps/server/src/occupancies/occupancies.module.ts index e583969..41a6427 100644 --- a/apps/server/src/occupancies/occupancies.module.ts +++ b/apps/server/src/occupancies/occupancies.module.ts @@ -8,9 +8,10 @@ import { OccupanciesService } from './occupancies.service'; import { OccupanciesController } from './occupancies.controller'; import { OperationLogsModule } from '../operation-logs/operation-logs.module'; import { NotificationsModule } from '../notifications/notifications.module'; +import { DepartmentsModule } from '../departments/departments.module'; @Module({ - imports: [TypeOrmModule.forFeature([Occupancy, Room, Student, Deposit]), OperationLogsModule, NotificationsModule], + imports: [TypeOrmModule.forFeature([Occupancy, Room, Student, Deposit]), OperationLogsModule, NotificationsModule, DepartmentsModule], controllers: [OccupanciesController], providers: [OccupanciesService], exports: [OccupanciesService], diff --git a/apps/server/src/occupancies/occupancies.service.ts b/apps/server/src/occupancies/occupancies.service.ts index dcc8104..5973046 100644 --- a/apps/server/src/occupancies/occupancies.service.ts +++ b/apps/server/src/occupancies/occupancies.service.ts @@ -15,6 +15,7 @@ import { Student } from '../entities/student.entity'; import { Deposit } from '../entities/deposit.entity'; import { CheckInDto, CheckOutDto, TransferRoomDto } from './dto/occupancy.dto'; import { RoomsService } from '../rooms/rooms.service'; +import { CampusScope } from '../common/campus-scope'; @Injectable() export class OccupanciesService { @@ -24,6 +25,7 @@ export class OccupanciesService { @InjectRepository(Student) private studentRepo: Repository, @InjectRepository(Deposit) private depositRepo: Repository, private dataSource: DataSource, + private readonly scope: CampusScope, ) {} async findAll(query?: { roomId?: number; studentId?: number; active?: boolean }) { @@ -32,6 +34,10 @@ export class OccupanciesService { .leftJoinAndSelect('o.student', 'student') .leftJoinAndSelect('o.room', 'room') .orderBy('o.checkInDate', 'DESC'); + const scopeIds = await this.scope.getScopeDepartmentIds(); + if (scopeIds) { + qb.andWhere('o.departmentId IN (:...scopeIds)', { scopeIds }); + } if (query?.roomId) qb.andWhere('o.roomId = :roomId', { roomId: query.roomId }); if (query?.studentId) qb.andWhere('o.studentId = :studentId', { studentId: query.studentId }); if (query?.active) qb.andWhere('o.checkOutDate IS NULL'); diff --git a/apps/server/src/operation-logs/operation-logs.controller.ts b/apps/server/src/operation-logs/operation-logs.controller.ts index 770408c..6afe52a 100644 --- a/apps/server/src/operation-logs/operation-logs.controller.ts +++ b/apps/server/src/operation-logs/operation-logs.controller.ts @@ -1,15 +1,16 @@ -import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { Controller, Get, Post, Body, Query, UseGuards, Request } from '@nestjs/common'; import { OperationLogsService } from './operation-logs.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RequirePermission } from '../auth/decorators/permission.decorator'; +import { extractRequestInfo } from '../common/request-utils'; @UseGuards(JwtAuthGuard) -@RequirePermission('log:view') @Controller('operation-logs') export class OperationLogsController { constructor(private service: OperationLogsService) {} @Get() + @RequirePermission('log:view') findAll( @Query('module') module?: string, @Query('userId') userId?: string, @@ -27,4 +28,23 @@ export class OperationLogsController { pageSize: pageSize ? +pageSize : 50, }); } + + @Post('audit') + async createAuditLog( + @Body() body: { module: string; action: string; targetId?: number; targetType?: string; detail?: string }, + @Request() req: any, + ) { + const { ipAddress, userAgent } = extractRequestInfo(req); + return this.service.log({ + userId: req.user?.id, + username: req.user?.username, + module: body.module, + action: body.action, + targetId: body.targetId, + targetType: body.targetType, + detail: body.detail, + ipAddress, + userAgent, + }); + } } diff --git a/apps/server/src/rbac/rbac.service.ts b/apps/server/src/rbac/rbac.service.ts index cc82d9c..a942790 100644 --- a/apps/server/src/rbac/rbac.service.ts +++ b/apps/server/src/rbac/rbac.service.ts @@ -68,6 +68,7 @@ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> = { code: 'attendance:view', name: '查看考勤', group: 'attendance' }, { code: 'attendance:create', name: '新增考勤', group: 'attendance' }, { code: 'attendance:edit', name: '编辑考勤', group: 'attendance' }, + { code: 'attendance:export', name: '导出考勤', group: 'attendance' }, ]; const PRESET_ROLES: Array<{ diff --git a/apps/server/src/rooms/rooms.controller.ts b/apps/server/src/rooms/rooms.controller.ts index e62400f..ecacbe6 100644 --- a/apps/server/src/rooms/rooms.controller.ts +++ b/apps/server/src/rooms/rooms.controller.ts @@ -63,15 +63,17 @@ export class RoomsController { { header: '楼层', key: 'floor', width: 8 }, { header: '额定人数', key: 'capacity', width: 10 }, { header: '宿舍类型', key: 'roomType', width: 12 }, + { header: '租赁类型(long/short)', key: 'rentalCategory', width: 18 }, + { header: '月租金', key: 'monthlyRate', width: 10 }, ]; - ws.getRow(1).font = { bold: true }; - ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } }; ws.addRow({ roomNumber: '4-102', building: '4号楼', floor: 1, capacity: 4, roomType: '四人间', + rentalCategory: 'long', + monthlyRate: 800, }); ws.addRow({ roomNumber: '2-201', @@ -79,6 +81,8 @@ export class RoomsController { floor: 2, capacity: 1, roomType: '单人间', + rentalCategory: 'short', + monthlyRate: 0, }); res.setHeader( 'Content-Type', @@ -106,6 +110,8 @@ export class RoomsController { { header: '当前入住', key: 'currentCount', width: 10 }, { header: '性别', key: 'gender', width: 8 }, { header: '状态', key: 'status', width: 10 }, + { header: '租赁类型', key: 'rentalCategory', width: 12 }, + { header: '月租金', key: 'monthlyRate', width: 10 }, ]; ws.getRow(1).font = { bold: true }; ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } }; @@ -125,6 +131,8 @@ export class RoomsController { currentCount: r.currentCount, gender: r.gender || '', status: statusMap[r.status] || r.status, + rentalCategory: r.rentalCategory === 'long' ? '长租' : '短租', + monthlyRate: r.monthlyRate ?? '', }); } res!.setHeader( @@ -245,15 +253,26 @@ export class RoomsController { floor?: number; capacity?: number; roomType?: string; + rentalCategory?: string; + monthlyRate?: number; }[] = []; ws.eachRow((row, idx) => { if (idx === 1) return; + const rentalCategoryRaw = String(row.getCell(6).value || '').trim().toLowerCase(); + const rentalCategory = + rentalCategoryRaw === 'long' || rentalCategoryRaw === 'short' + ? rentalCategoryRaw + : undefined; + const monthlyRateRaw = Number(row.getCell(7).value); + const monthlyRate = isNaN(monthlyRateRaw) ? undefined : monthlyRateRaw; rows.push({ roomNumber: String(row.getCell(1).value || ''), building: String(row.getCell(2).value || '') || undefined, floor: Number(row.getCell(3).value) || undefined, capacity: Number(row.getCell(4).value) || 4, roomType: String(row.getCell(5).value || '').trim() || undefined, + rentalCategory, + monthlyRate, }); }); const result = await this.service.batchImport(rows); diff --git a/apps/server/src/rooms/rooms.module.ts b/apps/server/src/rooms/rooms.module.ts index 13e71ad..0ee6446 100644 --- a/apps/server/src/rooms/rooms.module.ts +++ b/apps/server/src/rooms/rooms.module.ts @@ -6,9 +6,10 @@ import { RoomExpense } from '../entities/room-expense.entity'; import { RoomsService } from './rooms.service'; import { RoomsController } from './rooms.controller'; import { OperationLogsModule } from '../operation-logs/operation-logs.module'; +import { DepartmentsModule } from '../departments/departments.module'; @Module({ - imports: [TypeOrmModule.forFeature([Room, Occupancy, RoomExpense]), OperationLogsModule], + imports: [TypeOrmModule.forFeature([Room, Occupancy, RoomExpense]), OperationLogsModule, DepartmentsModule], controllers: [RoomsController], providers: [RoomsService], exports: [RoomsService], diff --git a/apps/server/src/rooms/rooms.service.ts b/apps/server/src/rooms/rooms.service.ts index f46a65f..a2273dd 100644 --- a/apps/server/src/rooms/rooms.service.ts +++ b/apps/server/src/rooms/rooms.service.ts @@ -1,6 +1,7 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, Like, IsNull, Not, In } from 'typeorm'; +import { CampusScope } from '../common/campus-scope'; import { Room } from '../entities/room.entity'; import { Occupancy } from '../entities/occupancy.entity'; import { RoomExpense } from '../entities/room-expense.entity'; @@ -12,6 +13,7 @@ export class RoomsService { @InjectRepository(Room) private repo: Repository, @InjectRepository(Occupancy) private occRepo: Repository, @InjectRepository(RoomExpense) private roomExpRepo: Repository, + private readonly scope: CampusScope, ) {} /** @@ -62,7 +64,8 @@ export class RoomsService { const where: any = {}; if (query?.building) where.building = query.building; if (!query?.includeArchived) where.status = Not('archived'); - return this.repo.find({ where, order: { roomNumber: 'ASC' } }); + const filteredWhere = await this.scope.filter(where); + return this.repo.find({ where: filteredWhere, order: { roomNumber: 'ASC' } }); } async findOne(id: number) { @@ -84,7 +87,8 @@ export class RoomsService { async getRoomOverview(query?: { includeArchived?: boolean }) { const where: any = {}; if (!query?.includeArchived) where.status = Not('archived'); - const rooms = await this.repo.find({ where, order: { building: 'ASC', roomNumber: 'ASC' } }); + const filteredWhere = await this.scope.filter(where); + const rooms = await this.repo.find({ where: filteredWhere, order: { building: 'ASC', roomNumber: 'ASC' } }); const result: any[] = []; for (const room of rooms) { const count = await this.occRepo.count({ @@ -161,12 +165,12 @@ export class RoomsService { async getRoomVisual() { const rooms = await this.repo.find({ - where: { status: Not('archived') }, + where: await this.scope.filter({ status: Not('archived') }), order: { building: 'ASC', roomNumber: 'ASC' }, }); const occupancies = await this.occRepo.find({ - where: { checkOutDate: IsNull() }, - relations: ['student'], + where: await this.scope.filter({ checkOutDate: IsNull() }), + relations: ['student', 'tenant'], order: { checkInDate: 'ASC' }, }); @@ -188,6 +192,8 @@ export class RoomsService { days, organization: occ.student?.organization || null, supervisor: occ.student?.supervisor || null, + tenantName: occ.tenant?.name || null, + tenantColor: occ.tenant?.color || null, }); } @@ -209,6 +215,9 @@ export class RoomsService { orgLabel = `存在${orgs.join('、')}人员`; } } + // 计算租户颜色:所有住户同一租户则使用该颜色 + const tenantColors = [...new Set(occ.map((o: any) => o.tenantColor).filter(Boolean))]; + const tenantColor: string | null = tenantColors.length === 1 ? tenantColors[0] : null; return { id: room.id, roomNumber: room.roomNumber, @@ -219,6 +228,7 @@ export class RoomsService { currentCount: occ.length, occupants: occ, orgLabel, + tenantColor, }; }), }; @@ -231,6 +241,8 @@ export class RoomsService { floor?: number; capacity?: number; roomType?: string; + rentalCategory?: string; + monthlyRate?: number; }[], ) { let imported = 0; @@ -254,6 +266,8 @@ export class RoomsService { floor: row.floor || parsed.floor || undefined, capacity: row.capacity || parsed.capacity || 4, roomType: row.roomType || parsed.roomType || undefined, + rentalCategory: row.rentalCategory || undefined, + monthlyRate: row.monthlyRate ?? undefined, }), ); imported++; diff --git a/apps/server/src/schedules/schedules.module.ts b/apps/server/src/schedules/schedules.module.ts index fa6d96d..4b66e5a 100644 --- a/apps/server/src/schedules/schedules.module.ts +++ b/apps/server/src/schedules/schedules.module.ts @@ -5,9 +5,10 @@ import { SchedulesService } from './schedules.service'; import { SchedulesController } from './schedules.controller'; import { OperationLogsModule } from '../operation-logs/operation-logs.module'; import { NotificationsModule } from '../notifications/notifications.module'; +import { DepartmentsModule } from '../departments/departments.module'; @Module({ - imports: [TypeOrmModule.forFeature([ClassSchedule]), OperationLogsModule, NotificationsModule], + imports: [TypeOrmModule.forFeature([ClassSchedule]), OperationLogsModule, NotificationsModule, DepartmentsModule], controllers: [SchedulesController], providers: [SchedulesService], exports: [SchedulesService], diff --git a/apps/server/src/schedules/schedules.service.ts b/apps/server/src/schedules/schedules.service.ts index 3adf074..6a4eb39 100644 --- a/apps/server/src/schedules/schedules.service.ts +++ b/apps/server/src/schedules/schedules.service.ts @@ -2,6 +2,7 @@ import { Injectable, NotFoundException, ConflictException } from '@nestjs/common import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { ClassSchedule } from '../entities'; +import { CampusScope } from '../common/campus-scope'; import { CreateScheduleDto, UpdateScheduleDto, @@ -14,10 +15,15 @@ export class SchedulesService { constructor( @InjectRepository(ClassSchedule) private readonly scheduleRepo: Repository, + private readonly scope: CampusScope, ) {} async findAll(query: QueryScheduleDto) { const qb = this.scheduleRepo.createQueryBuilder('cs'); + const scopeIds = await this.scope.getScopeDepartmentIds(); + if (scopeIds) { + qb.andWhere('cs.departmentId IN (:...scopeIds)', { scopeIds }); + } if (query.classroomId) qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId }); if (query.classId) qb.andWhere('cs.classId = :classId', { classId: query.classId }); @@ -102,7 +108,10 @@ export class SchedulesService { async getWeeklyView(query: WeeklyViewQueryDto) { const qb = this.scheduleRepo.createQueryBuilder('cs'); - + const scopeIds = await this.scope.getScopeDepartmentIds(); + if (scopeIds) { + qb.andWhere('cs.departmentId IN (:...scopeIds)', { scopeIds }); + } if (query.classroomId) { qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId }); } diff --git a/apps/server/src/students/dto/student.dto.ts b/apps/server/src/students/dto/student.dto.ts index 51e8f88..78002ad 100644 --- a/apps/server/src/students/dto/student.dto.ts +++ b/apps/server/src/students/dto/student.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsOptional, IsEnum } from 'class-validator'; +import { IsString, IsOptional, IsEnum, IsNumber } from 'class-validator'; export class CreateStudentDto { @IsString() @@ -32,6 +32,10 @@ export class CreateStudentDto { @IsString() organization?: string; + @IsOptional() + @IsNumber() + tenantId?: number; + @IsOptional() @IsString() supervisor?: string; @@ -70,6 +74,10 @@ export class UpdateStudentDto { @IsString() organization?: string; + @IsOptional() + @IsNumber() + tenantId?: number; + @IsOptional() @IsString() supervisor?: string; diff --git a/apps/server/src/students/students.controller.ts b/apps/server/src/students/students.controller.ts index 3018560..e592ade 100644 --- a/apps/server/src/students/students.controller.ts +++ b/apps/server/src/students/students.controller.ts @@ -12,7 +12,11 @@ import { Res, UseInterceptors, UploadedFile, + Inject, } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Tenant } from '../entities/tenant.entity'; import { FileInterceptor } from '@nestjs/platform-express'; import type { Response } from 'express'; import { StudentsService } from './students.service'; @@ -26,9 +30,11 @@ import * as ExcelJS from 'exceljs'; @UseGuards(JwtAuthGuard) @Controller('students') export class StudentsController { + constructor( private service: StudentsService, private logService: OperationLogsService, + @InjectRepository(Tenant) private tenantRepo: Repository, ) {} @Get() @@ -55,7 +61,7 @@ export class StudentsController { { header: '民族', key: 'ethnicity', width: 10 }, { header: '紧急联系人', key: 'emergencyContact', width: 15 }, { header: '紧急联系人电话', key: 'emergencyPhone', width: 18 }, - { header: '所属机构', key: 'organization', width: 18 }, + { header: '所属机构', key: 'tenant', width: 18 }, { header: '负责人/班主任', key: 'supervisor', width: 15 }, { header: '状态', key: 'status', width: 10 }, ]; @@ -76,7 +82,7 @@ export class StudentsController { ethnicity: s.ethnicity || '', emergencyContact: s.emergencyContact || '', emergencyPhone: s.emergencyPhone || '', - organization: s.organization || '', + tenant: s.tenant?.name || '', supervisor: s.supervisor || '', status: statusMap[s.status] || s.status, }); @@ -113,7 +119,7 @@ export class StudentsController { { header: '民族', key: 'ethnicity', width: 10 }, { header: '紧急联系人', key: 'emergencyContact', width: 15 }, { header: '紧急联系人电话', key: 'emergencyPhone', width: 18 }, - { header: '所属机构', key: 'organization', width: 18 }, + { header: '所属机构(租赁方名称)', key: 'tenant', width: 18 }, { header: '负责人/班主任', key: 'supervisor', width: 15 }, ]; ws.getRow(1).font = { bold: true }; @@ -126,7 +132,7 @@ export class StudentsController { ethnicity: '汉族', emergencyContact: '张父', emergencyPhone: '13900000000', - organization: '', + tenant: 'XX教育公司', supervisor: '', }); res.setHeader( @@ -251,8 +257,9 @@ export class StudentsController { ethnicity?: string; emergencyContact?: string; emergencyPhone?: string; - organization?: string; + tenant?: string; supervisor?: string; + tenantId?: number; }[] = []; ws.eachRow((row, idx) => { if (idx === 1) return; @@ -264,10 +271,19 @@ export class StudentsController { 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, + tenant: 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.tenant) { + const tenant = await this.tenantRepo.findOne({ where: { name: row.tenant } }); + if (tenant) { + row.tenantId = tenant.id; + } + } + } const result = await this.service.batchImport(rows); await this.logService.log({ userId: req.user?.id, diff --git a/apps/server/src/students/students.module.ts b/apps/server/src/students/students.module.ts index 2a9a8d9..781ecca 100644 --- a/apps/server/src/students/students.module.ts +++ b/apps/server/src/students/students.module.ts @@ -1,13 +1,14 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Student } from '../entities/student.entity'; +import { DepartmentsModule } from '../departments/departments.module'; import { ClassStudent } from '../entities/class-student.entity'; import { AttendanceRecord } from '../entities/attendance-record.entity'; import { StudentsService } from './students.service'; import { StudentsController } from './students.controller'; @Module({ - imports: [TypeOrmModule.forFeature([Student, ClassStudent, AttendanceRecord])], + imports: [TypeOrmModule.forFeature([Student, ClassStudent, AttendanceRecord]), DepartmentsModule], controllers: [StudentsController], providers: [StudentsService], exports: [StudentsService], diff --git a/apps/server/src/students/students.service.ts b/apps/server/src/students/students.service.ts index 6ee13b0..7d6bc20 100644 --- a/apps/server/src/students/students.service.ts +++ b/apps/server/src/students/students.service.ts @@ -1,6 +1,7 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, Like, Not, In } from 'typeorm'; +import { CampusScope } from '../common/campus-scope'; import { Student } from '../entities/student.entity'; import { ClassStudent } from '../entities/class-student.entity'; import { AttendanceRecord } from '../entities/attendance-record.entity'; @@ -13,6 +14,7 @@ export class StudentsService { @InjectRepository(Student) private repo: Repository, @InjectRepository(ClassStudent) private classStudentRepo: Repository, @InjectRepository(AttendanceRecord) private attendanceRepo: Repository, + private readonly scope: CampusScope, ) {} async findAll(query?: { name?: string; status?: string; includeArchived?: boolean }) { @@ -23,7 +25,8 @@ export class StudentsService { } else if (!query?.includeArchived) { where.status = Not('archived'); } - return this.repo.find({ where, order: { createdAt: 'DESC' } }); + const filteredWhere = await this.scope.filter(where); + return this.repo.find({ where: filteredWhere, order: { createdAt: 'DESC' }, relations: ['tenant'] }); } async findOne(id: number) { @@ -101,6 +104,7 @@ export class StudentsService { emergencyPhone?: string; organization?: string; supervisor?: string; + tenantId?: number; }[], ) { let imported = 0; @@ -126,9 +130,9 @@ export class StudentsService { emergencyPhone: row.emergencyPhone || undefined, organization: row.organization || undefined, supervisor: row.supervisor || undefined, + tenantId: row.tenantId || undefined, }), ); - imported++; } return { message: `成功导入 ${imported} 名学生,跳过 ${skipped} 条(重复或空行)`,