Files
gongxue-base/apps/server/src/dashboard/dashboard.service.ts

299 lines
11 KiB
TypeScript

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';
@Injectable()
export class DashboardService {
constructor(
@InjectRepository(Room) private roomRepo: Repository<Room>,
@InjectRepository(Student) private studentRepo: Repository<Student>,
@InjectRepository(Occupancy) private occRepo: Repository<Occupancy>,
@InjectRepository(Bill) private billRepo: Repository<Bill>,
@InjectRepository(RoomExpense) private expRepo: Repository<RoomExpense>,
@InjectRepository(Classroom) private classroomRepo: Repository<Classroom>,
@InjectRepository(ClassSchedule) private scheduleRepo: Repository<ClassSchedule>,
@InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
@InjectRepository(Class) private classRepo: Repository<Class>,
@InjectRepository(Deposit) private depositRepo: Repository<Deposit>,
@InjectRepository(ClassroomRental) private rentalRepo: Repository<ClassroomRental>,
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
) {}
async getStats() {
const today = new Date();
const todayStr = today.toISOString().slice(0, 10);
const currentMonth = todayStr.slice(0, 7); // YYYY-MM
const totalRooms = await this.roomRepo.count({ where: { status: Not('archived') } });
const totalStudents = await this.studentRepo.count({ where: { status: 'active' } });
const occupiedBeds = await this.occRepo.count({ where: { checkOutDate: IsNull() } });
const totalCapacity = await this.roomRepo
.createQueryBuilder('r')
.select('SUM(r.capacity)', 'total')
.where('r.status != :archived', { archived: 'archived' })
.getRawOne();
const cap = totalCapacity?.total || 0;
const occupancyRate = cap > 0 ? ((occupiedBeds / cap) * 100).toFixed(1) : 0;
const billStats = await this.billRepo
.createQueryBuilder('b')
.select('b.status', 'status')
.addSelect('COUNT(*)', 'count')
.addSelect('SUM(b.totalAmount)', 'total')
.groupBy('b.status')
.getRawMany();
// New fields
const classroomCount = await this.classroomRepo.count();
const occResult = await 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();
const occupiedClassrooms = parseInt(occResult?.cnt || '0', 10);
const classroomOccupancyRate = classroomCount > 0
? ((occupiedClassrooms / classroomCount) * 100).toFixed(1)
: 0;
const attTodayStats = await this.attendanceRepo
.createQueryBuilder('a')
.select('a.status', 'status')
.addSelect('COUNT(*)', 'count')
.where('a.attendanceDate = :today', { today: todayStr })
.groupBy('a.status')
.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 incomeResult = await 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();
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 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
.createQueryBuilder('d')
.select('SUM(d.amount)', 'total')
.where('d.status = :paid', { paid: 'paid' })
.andWhere('d.refundStatus IS NULL')
.getRawOne();
const pendingDeposits = parseFloat(pendingResult?.total || '0');
const activeRentals = await this.rentalRepo.count({ where: { endDate: MoreThanOrEqual(todayStr) } });
const occupancyByBuilding = await this.occRepo
.createQueryBuilder('o')
.leftJoin('o.room', 'r')
.select('r.building', 'building')
.addSelect('COUNT(*)', 'count')
.where('o.checkOutDate IS NULL')
.groupBy('r.building')
.getRawMany();
const attendanceByStatus = attTodayStats.reduce((acc, r) => {
acc[r.status] = parseInt(r.count, 10);
return acc;
}, {} as Record<string, number>);
const expenseByType = await 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();
return {
totalRooms,
totalStudents,
occupiedBeds,
totalCapacity: cap,
occupancyRate,
billStats,
classroomCount,
classroomOccupancyRate,
todayAttendanceRate,
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<string, { total: number; present: number }>();
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 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 (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<string, any[]>();
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 qb = this.expRepo
.createQueryBuilder('e')
.select('e.expenseType', 'type')
.addSelect('SUM(e.amount)', 'total')
.groupBy('e.expenseType');
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 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 (periodStart) qb.andWhere('e.periodStart >= :ps', { ps: periodStart });
if (periodEnd) qb.andWhere('e.periodEnd <= :pe', { pe: periodEnd });
return qb.getRawMany();
}
}