import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, IsNull, Not, MoreThanOrEqual } from 'typeorm'; import { Room } from '../entities/room.entity'; import { Student } from '../entities/student.entity'; import { Occupancy } from '../entities/occupancy.entity'; import { Bill } from '../entities/bill.entity'; import { RoomExpense } from '../entities/room-expense.entity'; import { Classroom } from '../entities/classroom.entity'; import { ClassSchedule } from '../entities/class-schedule.entity'; import { AttendanceRecord } from '../entities/attendance-record.entity'; 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, @InjectRepository(Occupancy) private occRepo: Repository, @InjectRepository(Bill) private billRepo: Repository, @InjectRepository(RoomExpense) private expRepo: Repository, @InjectRepository(Classroom) private classroomRepo: Repository, @InjectRepository(ClassSchedule) private scheduleRepo: Repository, @InjectRepository(AttendanceRecord) private attendanceRepo: Repository, @InjectRepository(Class) private classRepo: Repository, @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: 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' }); 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 billStatsQb = this.billRepo .createQueryBuilder('b') .select('b.status', 'status') .addSelect('COUNT(*)', 'count') .addSelect('SUM(b.totalAmount)', 'total') .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({ where: await this.scope.filter({}) }); 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 }); 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 attTodayQb = this.attendanceRepo .createQueryBuilder('a') .select('a.status', 'status') .addSelect('COUNT(*)', 'count') .where('a.attendanceDate = :today', { today: todayStr }) .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') .reduce((sum, r) => sum + parseInt(r.count, 10), 0); const todayAttendanceRate = todayTotal > 0 ? ((todayPresent / todayTotal) * 100).toFixed(1) : 0; 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) }); 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({ 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 pendingQb = this.depositRepo .createQueryBuilder('d') .select('SUM(d.amount)', 'total') .where('d.status = :paid', { paid: 'paid' }) .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: await this.scope.filter({ endDate: MoreThanOrEqual(todayStr) }) }); const occByBldQb = this.occRepo .createQueryBuilder('o') .leftJoin('o.room', 'r') .select('r.building', 'building') .addSelect('COUNT(*)', 'count') .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 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) }); if (scopeIds) expByTypeQb.andWhere('e.departmentId IN (:...scopeIds)', { scopeIds }); const expenseByType = await expByTypeQb.groupBy('e.expenseType').getRawMany(); return { totalRooms, totalStudents, occupiedBeds, totalCapacity: cap, occupancyRate, billStats, classroomCount, classroomOccupancyRate, todayPresent, monthlyIncome, classCount, teacherCount, pendingDeposits, activeRentals, occupancyByBuilding, attendanceByStatus, expenseByType, attendanceTrend, incomeTrend, }; } private async getAttendanceTrend(todayStr: string) { const thirtyDaysAgo = new Date(todayStr); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 29); const startStr = thirtyDaysAgo.toISOString().slice(0, 10); const rows = await this.attendanceRepo .createQueryBuilder('a') .select('a.attendanceDate', 'date') .addSelect('a.status', 'status') .addSelect('COUNT(*)', 'count') .where('a.attendanceDate >= :start', { start: startStr }) .andWhere('a.attendanceDate <= :today', { today: todayStr }) .groupBy('a.attendanceDate') .addGroupBy('a.status') .orderBy('a.attendanceDate', 'ASC') .getRawMany(); const dayMap = new Map(); for (const row of rows) { const d = dayMap.get(row.date) || { total: 0, present: 0 }; const cnt = parseInt(row.count, 10); d.total += cnt; if (row.status === 'present') d.present += cnt; dayMap.set(row.date, d); } return Array.from(dayMap.entries()).map(([date, d]) => ({ date, rate: d.total > 0 ? ((d.present / d.total) * 100).toFixed(1) : 0, })); } private async getIncomeTrend(currentMonth: string) { const results: { month: string; amount: number }[] = []; for (let i = 5; i >= 0; i--) { const d = new Date(`${currentMonth}-01`); d.setMonth(d.getMonth() - i); const m = d.toISOString().slice(0, 7); const row = await this.billRepo .createQueryBuilder('b') .select('SUM(b.totalAmount)', 'total') .where('b.status = :paid', { paid: 'paid' }) .andWhere('b.periodStart >= :start', { start: `${m}-01` }) .andWhere('b.periodStart < :end', { end: this.nextMonth(m) }) .getRawOne(); results.push({ month: m, amount: parseFloat(row?.total || '0'), }); } return results; } private nextMonth(ym: string): string { const d = new Date(`${ym}-01`); d.setMonth(d.getMonth() + 1); return d.toISOString().slice(0, 7) + '-01'; } // 甘特图数据:每个宿舍的入住时间线 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') .leftJoinAndSelect('o.room', 'room') .where('room.status != :archived', { archived: 'archived' }) .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 }); } if (query?.periodStart) { qb.andWhere('(o.checkOutDate IS NULL OR o.checkOutDate >= :ps)', { ps: query.periodStart }); } if (query?.periodEnd) { qb.andWhere('o.checkInDate <= :pe', { pe: query.periodEnd }); } const records = await qb.getMany(); // 按宿舍分组 const roomMap = new Map[]>(); for (const r of records) { const key = r.room?.roomNumber || String(r.roomId); if (!roomMap.has(key)) roomMap.set(key, []); roomMap.get(key)!.push({ studentName: r.student?.name || '未知', studentId: r.studentId, checkInDate: r.checkInDate, checkOutDate: r.checkOutDate, billingStartDate: r.billingStartDate, billingEndDate: r.billingEndDate, }); } return Array.from(roomMap.entries()).map(([roomNumber, occupancies]) => ({ roomNumber, 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(); } // 各宿舍费用排行 async getRoomExpenseRanking(periodStart?: string, periodEnd?: string) { const scopeIds = await this.scope.getScopeDepartmentIds(); const qb = this.expRepo .createQueryBuilder('e') .leftJoin('e.room', 'room') .select('room.roomNumber', 'roomNumber') .addSelect('SUM(e.amount)', 'total') .where('room.status != :archived', { archived: 'archived' }) .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), })); } async getClassroomUtilizationStats() { const scopeIds = await this.scope.getScopeDepartmentIds(); const totalClassrooms = await this.classroomRepo.count({ where: await this.scope.filter({ status: Not('archived') }), }); const today = new Date().toISOString().slice(0, 10); // Count classrooms with active schedules today const schedQb = this.scheduleRepo .createQueryBuilder('s') .select('COUNT(DISTINCT s.classroomId)', 'cnt') .where('s.status = :active', { active: 'active' }) .andWhere('s.scheduleType = :type', { type: 'INTERNAL' }) .andWhere('s.startDate <= :today AND s.endDate >= :today', { today }); if (scopeIds) schedQb.andWhere('s.departmentId IN (:...scopeIds)', { scopeIds }); const schedResult = await schedQb.getRawOne(); // Count classrooms with active rentals today const rentalQb = this.rentalRepo .createQueryBuilder('r') .select('COUNT(DISTINCT r.classroomId)', 'cnt') .where('r.status != :cancelled', { cancelled: 'cancelled' }) .andWhere('r.startDate <= :today AND r.endDate >= :today', { today }); if (scopeIds) rentalQb.andWhere('r.departmentId IN (:...scopeIds)', { scopeIds }); const rentalResult = await rentalQb.getRawOne(); // Combine: use Set merge of both const combinedQb = this.scheduleRepo .createQueryBuilder('s') .select('s.classroomId') .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) combinedQb.andWhere('s.departmentId IN (:...scopeIds)', { scopeIds }); const schedIds = await combinedQb.getRawMany(); const combinedRentalQb = this.rentalRepo .createQueryBuilder('r') .select('r.classroomId') .where('r.status != :cancelled', { cancelled: 'cancelled' }) .andWhere('r.startDate <= :today AND r.endDate >= :today', { today }) .groupBy('r.classroomId'); if (scopeIds) combinedRentalQb.andWhere('r.departmentId IN (:...scopeIds)', { scopeIds }); const rentalIds = await combinedRentalQb.getRawMany(); const allInUseIds = new Set([ ...schedIds.map((s) => s.classroomId), ...rentalIds.map((r) => r.classroomId), ]); const scheduleCount = parseInt(schedResult?.cnt || '0', 10); const rentalCount = parseInt(rentalResult?.cnt || '0', 10); const inUseCount = allInUseIds.size; const utilizationRate = totalClassrooms > 0 ? ((inUseCount / totalClassrooms) * 100).toFixed(1) : '0'; return { totalClassrooms, inUseCount, utilizationRate, scheduleCount, rentalCount, }; } }