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

403 lines
16 KiB
TypeScript

import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull, Not, MoreThanOrEqual, In } from 'typeorm';
import { Room } from '../entities/room.entity';
import { Student } from '../entities/student.entity';
import { Occupancy } from '../entities/occupancy.entity';
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 { ClassStudent } from '../entities/class-student.entity';
import { DashboardQueriesService } from './dashboard-queries.service';
interface AgentAttendanceStatusRow {
status: string;
count: string | number;
}
@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>,
@InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
private readonly queries: DashboardQueriesService,
) {}
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
if (canManageAll) return undefined;
const assignments = await this.classTeacherRepo.find({ where: { userId } });
return [...new Set(assignments.map((assignment) => assignment.classId))];
}
async agentGetDashboardStats(userId: number, canManageAll: boolean) {
const accessibleClassIds = await this.getAccessibleClassIds(userId, canManageAll);
const today = this.getChinaDate(new Date());
const totalStudents = accessibleClassIds
? await this.countStudentsInClasses(accessibleClassIds)
: await this.studentRepo.count({ where: { status: 'active' } });
const classCount = accessibleClassIds
? accessibleClassIds.length
: await this.classRepo.count({ where: { isArchived: false } });
const attendanceQb = this.attendanceRepo
.createQueryBuilder('attendance')
.select('attendance.status', 'status')
.addSelect('COUNT(attendance.id)', 'count')
.where('attendance.attendanceDate = :today', { today });
this.applyClassScope(attendanceQb, 'attendance', accessibleClassIds);
const rows = await attendanceQb
.groupBy('attendance.status')
.getRawMany<AgentAttendanceStatusRow>();
const attendanceByStatus = rows.reduce(
(result, row) => {
result[String(row.status)] = Number(row.count || 0);
return result;
},
{} as Record<string, number>,
);
const attendanceTotal = Object.values(attendanceByStatus).reduce<number>(
(sum, count) => sum + Number(count),
0,
);
const present = attendanceByStatus.present ?? 0;
return {
date: today,
totalStudents,
classCount,
attendanceTotal,
present,
attendanceRate: attendanceTotal ? Number(((present / attendanceTotal) * 100).toFixed(1)) : 0,
attendanceByStatus,
};
}
async getStats(accessibleClassIds?: number[]) {
const todayStr = this.getChinaDate(new Date());
const currentMonth = todayStr.slice(0, 7); // YYYY-MM
const totalRooms = await this.roomRepo.count({ where: { status: Not('archived') } });
const totalStudents = accessibleClassIds
? await this.countStudentsInClasses(accessibleClassIds)
: await this.studentRepo.count({ where: { status: 'active' } });
const occupiedBeds = await this.occRepo.count({ where: { checkOutDate: IsNull() } });
const capQb = this.roomRepo
.createQueryBuilder('r')
.select('SUM(r.capacity)', 'total')
.where('r.status != :archived', { archived: 'archived' });
const totalCapacity = await capQb.getRawOne<{ total: string | number | null }>();
// MySQL 的 SUM() 聚合默认以字符串返回,需显式转成 number
const cap = Number(totalCapacity?.total ?? 0) || 0;
const occupancyRate = cap > 0 ? ((occupiedBeds / cap) * 100).toFixed(1) : '0.0';
const billStatsQb = this.billRepo
.createQueryBuilder('b')
.select('b.status', 'status')
.addSelect('COUNT(*)', 'count')
.addSelect('SUM(b.totalAmount)', 'total')
.groupBy('b.status');
const billStats = await billStatsQb.getRawMany<{
status: string;
count: string | number;
total: string | number;
}>();
// New fields
const classroomCount = await this.classroomRepo.count({ where: {} });
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 });
const occResult = await occQb.getRawOne<{ cnt: string | number | null }>();
const occupiedClassrooms = parseInt(String(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 });
this.applyClassScope(attTodayQb, 'a', accessibleClassIds);
attTodayQb.groupBy('a.status');
const attTodayStats = await attTodayQb.getRawMany<{ status: string; count: string | number }>();
const todayPresent = attTodayStats
.filter((r) => r.status === 'present')
.reduce((sum, r) => sum + parseInt(String(r.count), 10), 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) });
const incomeResult = await incomeQb.getRawOne<{ total: string | number | null }>();
const monthlyIncome = parseFloat(String(incomeResult?.total || '0'));
const attendanceTrend = await this.getAttendanceTrend(todayStr, accessibleClassIds);
const incomeTrend = await this.getIncomeTrend(currentMonth);
const classCount = accessibleClassIds
? accessibleClassIds.length
: await this.classRepo.count({ where: {} });
const teacherResult = await this.classTeacherRepo
.createQueryBuilder('ct')
.select('COUNT(DISTINCT ct.userId)', 'cnt')
.getRawOne<{ cnt: string | number | null }>();
const teacherCount = parseInt(String(teacherResult?.cnt || '0'), 10);
const pendingQb = this.depositRepo
.createQueryBuilder('d')
.select('SUM(d.amount)', 'total')
.where('d.status = :paid', { paid: 'paid' });
const pendingResult = await pendingQb.getRawOne<{ total: string | number | null }>();
const pendingDeposits = parseFloat(String(pendingResult?.total || '0'));
const activeRentals = await this.rentalRepo.count({
where: { status: 'active' as const, 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');
const occupancyByBuilding = await occByBldQb
.groupBy('r.building')
.getRawMany<{ building: string | null; count: string | number }>();
const attendanceByStatus = attTodayStats.reduce(
(acc, r) => {
acc[r.status] = parseInt(String(r.count), 10);
return acc;
},
{} as Record<string, number>,
);
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) });
const expenseByType = await expByTypeQb
.groupBy('e.expenseType')
.getRawMany<{ type: string; total: string | number }>();
return {
totalRooms,
totalStudents,
occupiedBeds,
totalCapacity: cap,
occupancyRate,
billStats,
classroomCount,
classroomOccupancyRate,
todayPresent,
monthlyIncome,
classCount,
teacherCount,
pendingDeposits,
activeRentals,
occupancyByBuilding,
attendanceByStatus,
expenseByType,
attendanceTrend,
incomeTrend,
};
}
private applyClassScope(
qb: { andWhere: (condition: string, parameters?: Record<string, unknown>) => unknown },
alias: string,
accessibleClassIds?: number[],
) {
if (accessibleClassIds) {
if (accessibleClassIds.length === 0) {
qb.andWhere('1 = 0');
return;
}
qb.andWhere(`${alias}.classId IN (:...accessibleClassIds)`, { accessibleClassIds });
}
}
private async countStudentsInClasses(accessibleClassIds: number[]) {
if (accessibleClassIds.length === 0) return 0;
const classStudents = await this.classStudentRepo.find({
where: { classId: In(accessibleClassIds), status: 'active' },
});
return new Set(classStudents.map((item) => item.studentId)).size;
}
async getAttendanceTrend(todayStr: string, accessibleClassIds?: number[]) {
return this.queries.getAttendanceTrend(this.attendanceRepo, todayStr, accessibleClassIds);
}
async getIncomeTrend(currentMonth: string) {
return this.queries.getIncomeTrend(this.billRepo, currentMonth);
}
async getGanttData(query?: { periodStart?: string; periodEnd?: string; building?: string }) {
return this.queries.getGanttData(this.occRepo, (a, b) => this.assertPeriodRange(a, b), query);
}
async getExpenseStats(periodStart?: string, periodEnd?: string) {
return this.queries.getExpenseStats(this.expRepo, (a, b) => this.assertPeriodRange(a, b), periodStart, periodEnd);
}
async getRoomExpenseRanking(periodStart?: string, periodEnd?: string) {
return this.queries.getRoomExpenseRanking(this.expRepo, (a, b) => this.assertPeriodRange(a, b), periodStart, periodEnd);
}
async getClassAttendanceRanking(accessibleClassIds?: number[]) {
return this.queries.getClassAttendanceRanking(
this.attendanceRepo,
(qb, alias, ids) => this.applyClassScope(qb, alias, ids),
accessibleClassIds,
);
}
private nextMonth(ym: string): string {
const d = new Date(`${ym}-01`);
d.setMonth(d.getMonth() + 1);
return d.toISOString().slice(0, 7) + '-01';
}
async getClassroomOccupancy() {
const classrooms = await this.classroomRepo.find({
where: { status: 'available' as const },
order: { building: 'ASC', name: 'ASC' },
});
const today = this.getChinaDate(new Date());
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');
const schedules = await schedQb.getRawMany<{ classroomId: number; weekDays: string | number }>();
const rentalQb = this.rentalRepo
.createQueryBuilder('r')
.select('r.classroomId', 'classroomId')
.addSelect('COUNT(*)', 'rentalCount')
.where('r.status = :active', { active: 'active' })
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today })
.groupBy('r.classroomId');
const rentals = await rentalQb.getRawMany<{ classroomId: number; rentalCount: string | number }>();
const sMap: Record<number, number> = {};
const rMap: Record<number, number> = {};
for (const s of schedules) sMap[s.classroomId] = parseInt(String(s.weekDays), 10);
for (const r of rentals) rMap[r.classroomId] = parseInt(String(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),
}));
}
private assertPeriodRange(periodStart?: string, periodEnd?: string) {
if (periodStart && periodEnd && periodStart > periodEnd) {
throw new BadRequestException('结束日期不能早于开始日期');
}
}
private getChinaDate(date: Date): string {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(date);
const values = Object.fromEntries(
parts.filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]),
);
return `${values.year}-${values.month}-${values.day}`;
}
async getClassroomUtilizationStats() {
const totalClassrooms = await this.classroomRepo.count({
where: { status: 'available' as const },
});
const today = this.getChinaDate(new Date());
// 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 });
const schedResult = await schedQb.getRawOne<{ cnt: string | number | null }>();
// Count classrooms with active rentals today
const rentalQb = this.rentalRepo
.createQueryBuilder('r')
.select('COUNT(DISTINCT r.classroomId)', 'cnt')
.where('r.status = :active', { active: 'active' })
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today });
const rentalResult = await rentalQb.getRawOne<{ cnt: string | number | null }>();
// 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');
const schedIds = await combinedQb.getRawMany<{ classroomId: number }>();
const combinedRentalQb = this.rentalRepo
.createQueryBuilder('r')
.select('r.classroomId')
.where('r.status = :active', { active: 'active' })
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today })
.groupBy('r.classroomId');
const rentalIds = await combinedRentalQb.getRawMany<{ classroomId: number }>();
const allInUseIds = new Set([
...schedIds.map((s) => s.classroomId),
...rentalIds.map((r) => r.classroomId),
]);
const scheduleCount = parseInt(String(schedResult?.cnt || '0'), 10);
const rentalCount = parseInt(String(rentalResult?.cnt || '0'), 10);
const inUseCount = allInUseIds.size;
const utilizationRate =
totalClassrooms > 0 ? ((inUseCount / totalClassrooms) * 100).toFixed(1) : '0';
return {
totalClassrooms,
inUseCount,
utilizationRate,
scheduleCount,
rentalCount,
};
}
}