forked from wangziqi/gongxue-base
490 lines
19 KiB
TypeScript
490 lines
19 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';
|
|
|
|
@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>,
|
|
) {}
|
|
|
|
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 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();
|
|
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');
|
|
const billStats = await billStatsQb.getRawMany();
|
|
|
|
// 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();
|
|
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 });
|
|
this.applyClassScope(attTodayQb, 'a', accessibleClassIds);
|
|
attTodayQb.groupBy('a.status');
|
|
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) });
|
|
const incomeResult = await incomeQb.getRawOne();
|
|
const monthlyIncome = parseFloat(incomeResult?.total || '0');
|
|
|
|
const attendanceTrend = await this.getAttendanceTrend(todayStr, accessibleClassIds);
|
|
const incomeTrend = await this.getIncomeTrend(currentMonth);
|
|
|
|
// --- New stats ---
|
|
const classCount = accessibleClassIds
|
|
? accessibleClassIds.length
|
|
: await this.classRepo.count({ where: {} });
|
|
|
|
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' });
|
|
const pendingResult = await pendingQb.getRawOne();
|
|
const pendingDeposits = parseFloat(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();
|
|
|
|
const attendanceByStatus = attTodayStats.reduce(
|
|
(acc, r) => {
|
|
acc[r.status] = parseInt(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();
|
|
|
|
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;
|
|
}
|
|
|
|
private async getAttendanceTrend(todayStr: string, accessibleClassIds?: number[]) {
|
|
const thirtyDaysAgo = new Date(todayStr);
|
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 29);
|
|
const startStr = thirtyDaysAgo.toISOString().slice(0, 10);
|
|
|
|
const trendQb = 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 });
|
|
this.applyClassScope(trendQb, 'a', accessibleClassIds);
|
|
|
|
const rows = await trendQb
|
|
.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 }) {
|
|
this.assertPeriodRange(query?.periodStart, query?.periodEnd);
|
|
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, Record<string, unknown>[]>();
|
|
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) {
|
|
this.assertPeriodRange(periodStart, periodEnd);
|
|
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) {
|
|
this.assertPeriodRange(periodStart, periodEnd);
|
|
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();
|
|
}
|
|
|
|
// 班级考勤排行
|
|
async getClassAttendanceRanking(accessibleClassIds?: number[]) {
|
|
if (accessibleClassIds?.length === 0) return { top: [], bottom: [] };
|
|
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');
|
|
this.applyClassScope(qb, 'a', accessibleClassIds);
|
|
qb.groupBy('class.id').addGroupBy('class.name').addGroupBy('a.status');
|
|
const raw = await qb.getRawMany();
|
|
|
|
const classMap = new Map<number, { className: string; present: number; total: number }>();
|
|
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 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();
|
|
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();
|
|
const sMap: Record<number, number> = {};
|
|
const rMap: Record<number, number> = {};
|
|
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),
|
|
}));
|
|
}
|
|
|
|
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();
|
|
|
|
// 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();
|
|
|
|
// 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();
|
|
|
|
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();
|
|
|
|
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,
|
|
};
|
|
}
|
|
}
|