feat: 重构各业务模块管理页面与服务
This commit is contained in:
241
apps/server/src/dashboard/dashboard-queries.service.ts
Normal file
241
apps/server/src/dashboard/dashboard-queries.service.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Occupancy } from '../entities/occupancy.entity';
|
||||
import { Bill } from '../entities/bill.entity';
|
||||
import { RoomExpense } from '../entities/room-expense.entity';
|
||||
import { AttendanceRecord } from '../entities/attendance-record.entity';
|
||||
|
||||
export function nextMonth(ym: string): string {
|
||||
const d = new Date(`${ym}-01`);
|
||||
d.setMonth(d.getMonth() + 1);
|
||||
return d.toISOString().slice(0, 7) + '-01';
|
||||
}
|
||||
|
||||
export function 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 });
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DashboardQueriesService {
|
||||
constructor(
|
||||
@InjectRepository(AttendanceRecord) private readonly attendanceRepo: Repository<AttendanceRecord>,
|
||||
@InjectRepository(Bill) private readonly billRepo: Repository<Bill>,
|
||||
@InjectRepository(Occupancy) private readonly occRepo: Repository<Occupancy>,
|
||||
@InjectRepository(RoomExpense) private readonly expRepo: Repository<RoomExpense>,
|
||||
) {}
|
||||
|
||||
async getAttendanceTrend(
|
||||
attendanceRepo: Repository<AttendanceRecord>,
|
||||
todayStr: string,
|
||||
accessibleClassIds?: number[],
|
||||
) {
|
||||
const thirtyDaysAgo = new Date(todayStr);
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 29);
|
||||
const startStr = thirtyDaysAgo.toISOString().slice(0, 10);
|
||||
|
||||
const trendQb = 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 });
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
async getIncomeTrend(
|
||||
billRepo: Repository<Bill>,
|
||||
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 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: nextMonth(m) })
|
||||
.getRawOne();
|
||||
|
||||
results.push({
|
||||
month: m,
|
||||
amount: parseFloat(row?.total || '0'),
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
|
||||
// 甘特图数据:每个宿舍的入住时间线
|
||||
|
||||
async getGanttData(
|
||||
occRepo: Repository<Occupancy>,
|
||||
assertPeriodRange: (start?: string, end?: string) => void,
|
||||
query?: { periodStart?: string; periodEnd?: string; building?: string },
|
||||
) {
|
||||
assertPeriodRange(query?.periodStart, query?.periodEnd);
|
||||
const qb = 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(
|
||||
expRepo: Repository<RoomExpense>,
|
||||
assertPeriodRange: (start?: string, end?: string) => void,
|
||||
periodStart?: string,
|
||||
periodEnd?: string,
|
||||
) {
|
||||
assertPeriodRange(periodStart, periodEnd);
|
||||
const qb = 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(
|
||||
expRepo: Repository<RoomExpense>,
|
||||
assertPeriodRange: (start?: string, end?: string) => void,
|
||||
periodStart?: string,
|
||||
periodEnd?: string,
|
||||
) {
|
||||
assertPeriodRange(periodStart, periodEnd);
|
||||
const qb = 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(
|
||||
attendanceRepo: Repository<AttendanceRecord>,
|
||||
applyClassScope: (
|
||||
qb: { andWhere: (condition: string, parameters?: Record<string, unknown>) => unknown },
|
||||
alias: string,
|
||||
accessibleClassIds?: number[],
|
||||
) => void,
|
||||
accessibleClassIds?: number[],
|
||||
) {
|
||||
if (accessibleClassIds?.length === 0) return { top: [], bottom: [] };
|
||||
const qb = attendanceRepo
|
||||
.createQueryBuilder('a')
|
||||
.leftJoin('a.class', 'class')
|
||||
.select('class.id', 'classId')
|
||||
.addSelect('class.name', 'className')
|
||||
.addSelect('a.status', 'status')
|
||||
.addSelect('COUNT(*)', 'count');
|
||||
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() };
|
||||
}
|
||||
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { ClassroomRental } from '../entities/classroom-rental.entity';
|
||||
import { ClassTeacher } from '../entities/class-teacher.entity';
|
||||
import { ClassStudent } from '../entities/class-student.entity';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
import { DashboardQueriesService } from './dashboard-queries.service';
|
||||
import { DashboardController } from './dashboard.controller';
|
||||
|
||||
@Module({
|
||||
@@ -35,7 +36,7 @@ import { DashboardController } from './dashboard.controller';
|
||||
]),
|
||||
],
|
||||
controllers: [DashboardController],
|
||||
providers: [DashboardService],
|
||||
providers: [DashboardService, DashboardQueriesService],
|
||||
exports: [DashboardService],
|
||||
})
|
||||
export class DashboardModule {}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { DashboardService } from './dashboard.service';
|
||||
import { DashboardQueriesService } from './dashboard-queries.service';
|
||||
|
||||
const queriesService = (attendanceRepo?: unknown) =>
|
||||
new DashboardQueriesService(attendanceRepo as never, {} as never, {} as never, {} as never);
|
||||
|
||||
const createQb = () => ({
|
||||
leftJoin: jest.fn().mockReturnThis(),
|
||||
@@ -32,7 +36,7 @@ describe('DashboardService — teacher class scope', () => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{},
|
||||
queriesService(attendanceRepo),
|
||||
);
|
||||
|
||||
await service.getClassAttendanceRanking([8, 9]);
|
||||
@@ -51,6 +55,7 @@ describe('DashboardService — boundary conditions', () => {
|
||||
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
|
||||
{} as never, attendanceRepo as never, {} as never, {} as never, {} as never,
|
||||
{} as never, {} as never,
|
||||
queriesService(attendanceRepo),
|
||||
);
|
||||
|
||||
await (service as unknown as {
|
||||
@@ -69,6 +74,7 @@ describe('DashboardService — boundary conditions', () => {
|
||||
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
|
||||
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
|
||||
{} as never,
|
||||
queriesService(),
|
||||
);
|
||||
await expect((service[method] as (...values: never[]) => Promise<unknown>)(...(args as never[])))
|
||||
.rejects.toThrow('结束日期不能早于开始日期');
|
||||
@@ -79,6 +85,7 @@ describe('DashboardService — boundary conditions', () => {
|
||||
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
|
||||
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
|
||||
{} as never,
|
||||
queriesService(),
|
||||
);
|
||||
expect((service as unknown as { getChinaDate: (date: Date) => string })
|
||||
.getChinaDate(new Date('2026-07-13T16:30:00.000Z'))).toBe('2026-07-14');
|
||||
|
||||
@@ -14,6 +14,7 @@ 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;
|
||||
@@ -36,6 +37,7 @@ export class DashboardService {
|
||||
@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> {
|
||||
@@ -50,24 +52,39 @@ export class DashboardService {
|
||||
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 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 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 };
|
||||
return {
|
||||
date: today,
|
||||
totalStudents,
|
||||
classCount,
|
||||
attendanceTotal,
|
||||
present,
|
||||
attendanceRate: attendanceTotal ? Number(((present / attendanceTotal) * 100).toFixed(1)) : 0,
|
||||
attendanceByStatus,
|
||||
};
|
||||
}
|
||||
|
||||
async getStats(accessibleClassIds?: number[]) {
|
||||
@@ -117,12 +134,9 @@ export class DashboardService {
|
||||
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')
|
||||
@@ -135,7 +149,6 @@ export class DashboardService {
|
||||
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: {} });
|
||||
@@ -226,64 +239,32 @@ export class DashboardService {
|
||||
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,
|
||||
}));
|
||||
async getAttendanceTrend(todayStr: string, accessibleClassIds?: number[]) {
|
||||
return this.queries.getAttendanceTrend(this.attendanceRepo, todayStr, accessibleClassIds);
|
||||
}
|
||||
|
||||
private async getIncomeTrend(currentMonth: string) {
|
||||
const results: { month: string; amount: number }[] = [];
|
||||
async getIncomeTrend(currentMonth: string) {
|
||||
return this.queries.getIncomeTrend(this.billRepo, currentMonth);
|
||||
}
|
||||
|
||||
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);
|
||||
async getGanttData(query?: { periodStart?: string; periodEnd?: string; building?: string }) {
|
||||
return this.queries.getGanttData(this.occRepo, (a, b) => this.assertPeriodRange(a, b), query);
|
||||
}
|
||||
|
||||
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();
|
||||
async getExpenseStats(periodStart?: string, periodEnd?: string) {
|
||||
return this.queries.getExpenseStats(this.expRepo, (a, b) => this.assertPeriodRange(a, b), periodStart, periodEnd);
|
||||
}
|
||||
|
||||
results.push({
|
||||
month: m,
|
||||
amount: parseFloat(row?.total || '0'),
|
||||
});
|
||||
}
|
||||
async getRoomExpenseRanking(periodStart?: string, periodEnd?: string) {
|
||||
return this.queries.getRoomExpenseRanking(this.expRepo, (a, b) => this.assertPeriodRange(a, b), periodStart, periodEnd);
|
||||
}
|
||||
|
||||
return results;
|
||||
async getClassAttendanceRanking(accessibleClassIds?: number[]) {
|
||||
return this.queries.getClassAttendanceRanking(
|
||||
this.attendanceRepo,
|
||||
(qb, alias, ids) => this.applyClassScope(qb, alias, ids),
|
||||
accessibleClassIds,
|
||||
);
|
||||
}
|
||||
|
||||
private nextMonth(ym: string): string {
|
||||
@@ -292,114 +273,6 @@ export class DashboardService {
|
||||
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 },
|
||||
|
||||
Reference in New Issue
Block a user