feat: integrate CampusScope.filter() into all business services (12 services + 12 modules)

This commit is contained in:
2026-07-05 23:58:51 +08:00
parent eeae06fe30
commit cd6364268d
40 changed files with 949 additions and 172 deletions

View File

@@ -18,6 +18,7 @@ import {
QueryAttendanceRecordsDto,
QueryDingRawDto,
MatchDingRecordDto,
AttendanceReportQueryDto,
} from './dto/attendance.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
@@ -56,7 +57,7 @@ export class AttendanceController {
// ── Export attendance records ──
@Get('attendance-records/export')
@RequirePermission('attendance:view')
@RequirePermission('attendance:export')
async exportRecords(
@Query() query: QueryAttendanceRecordsDto,
@Res() res: Response,
@@ -167,4 +168,83 @@ export class AttendanceController {
});
return result;
}
}
// ── Attendance class-based report export ──
@Get('attendance-records/report')
@RequirePermission('attendance:export')
async exportReport(
@Query() query: AttendanceReportQueryDto,
@Res() res: Response,
@Request() req: any,
) {
const reportData = await this.service.getReport(query);
const workbook = new ExcelJS.Workbook();
const ws = workbook.addWorksheet('考勤统计报表');
ws.columns = [
{ header: '班级名称', key: 'className', width: 30 },
{ header: '总记录数', key: 'total', width: 12 },
{ header: '出勤', key: 'present', width: 10 },
{ header: '出勤率', key: 'presentRate', width: 10 },
{ header: '缺勤', key: 'absent', width: 10 },
{ header: '缺勤率', key: 'absentRate', width: 10 },
{ header: '迟到', key: 'late', width: 10 },
{ header: '迟到率', key: 'lateRate', width: 10 },
{ header: '请假', key: 'leave', width: 10 },
{ header: '请假率', key: 'leaveRate', width: 10 },
];
ws.getRow(1).font = { bold: true };
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
for (const row of reportData) {
ws.addRow({
className: row.className,
total: row.total,
present: row.present,
presentRate: `${row.presentRate}%`,
absent: row.absent,
absentRate: `${row.absentRate}%`,
late: row.late,
lateRate: `${row.lateRate}%`,
leave: row.leave,
leaveRate: `${row.leaveRate}%`,
});
}
// Audit log
const { ipAddress, userAgent } = extractRequestInfo(req);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '考勤管理',
action: '导出考勤报表',
detail: `classId=${query.classId || '全部'} ${query.dateFrom || ''}~${query.dateTo || ''}`,
ipAddress,
userAgent,
});
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', 'attachment; filename=attendance-report.xlsx');
await workbook.xlsx.write(res);
res.end();
}
// ── Abnormal attendance alerts ──
@Get('attendance-records/alerts')
@RequirePermission('attendance:view')
getAlerts(
@Query('days') days?: string,
@Query('threshold') threshold?: string,
) {
return this.service.getAlerts(
days ? +days : 14,
threshold ? +threshold : 3,
);
}
@Post('ding-attendance-raw/auto-match')
@RequirePermission('attendance:edit')
async autoMatch() {
return this.service.autoMatchDingRecords();
}
}

View File

@@ -4,11 +4,12 @@ import { AttendanceRecord, DingAttendanceRaw, Student, Class } from '../entities
import { AttendanceService } from './attendance.service';
import { AttendanceController } from './attendance.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
import { DepartmentsModule } from '../departments/departments.module';
@Module({
imports: [
TypeOrmModule.forFeature([AttendanceRecord, DingAttendanceRaw, Student, Class]),
OperationLogsModule,
DepartmentsModule,
],
controllers: [AttendanceController],
providers: [AttendanceService],

View File

@@ -5,13 +5,15 @@ import {
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In, Between } from 'typeorm';
import { AttendanceRecord, DingAttendanceRaw, Class } from '../entities';
import { AttendanceRecord, DingAttendanceRaw, Class, Student } from '../entities';
import { CampusScope } from '../common/campus-scope';
import {
BatchCreateAttendanceDto,
AttendanceSummaryQueryDto,
AttendanceCalendarQueryDto,
QueryDingRawDto,
MatchDingRecordDto,
AttendanceReportQueryDto,
} from './dto/attendance.dto';
@Injectable()
@@ -23,6 +25,9 @@ export class AttendanceService {
private dingRawRepo: Repository<DingAttendanceRaw>,
@InjectRepository(Class)
private classRepo: Repository<Class>,
@InjectRepository(Student)
private studentRepo: Repository<Student>,
private readonly scope: CampusScope,
) {}
// ── Batch create attendance records ──
@@ -50,7 +55,10 @@ export class AttendanceService {
// ── Attendance summary ──
async getSummary(query: AttendanceSummaryQueryDto) {
const qb = this.attendanceRepo.createQueryBuilder('ar');
const scopeIds = await this.scope.getScopeDepartmentIds();
if (scopeIds) {
qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
}
if (query.classId) {
qb.andWhere('ar.classId = :classId', { classId: query.classId });
}
@@ -151,11 +159,14 @@ export class AttendanceService {
const page = query.page || 1;
const pageSize = query.pageSize || 20;
const qb = this.attendanceRepo
.createQueryBuilder('ar')
.leftJoinAndSelect('ar.student', 'student')
.leftJoinAndSelect('ar.class', 'class');
const qb = this.attendanceRepo.createQueryBuilder('ar');
const scopeIds = await this.scope.getScopeDepartmentIds();
if (scopeIds) {
qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
}
qb.leftJoinAndSelect('ar.student', 'student')
.leftJoinAndSelect('ar.class', 'class');
if (query.classId) {
qb.andWhere('ar.classId = :classId', { classId: query.classId });
}
@@ -184,17 +195,25 @@ export class AttendanceService {
// ── Get distinct classes with attendance records ──
async getClasses() {
const rows = await this.attendanceRepo
const qb = this.attendanceRepo
.createQueryBuilder('ar')
.select('DISTINCT ar.classId', 'classId')
.where('ar.classId IS NOT NULL')
.where('ar.classId IS NOT NULL');
const scopeIds = await this.scope.getScopeDepartmentIds();
if (scopeIds) {
qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
}
const rows = await qb
.orderBy('ar.classId', 'ASC')
.getRawMany();
const classIds = rows.map((r) => r.classId).filter(Boolean) as number[];
if (classIds.length === 0) return [];
const classes = await this.classRepo.find({ where: { id: In(classIds) } });
const where = await this.scope.filter({ id: In(classIds) });
const classes = await this.classRepo.find({ where });
const nameMap = new Map(classes.map((c) => [c.id, c.name]));
return classIds.map((id) => ({ classId: id, className: nameMap.get(id) || `班级${id}` }));
}
@@ -207,7 +226,7 @@ export class AttendanceService {
}
return this.dingRawRepo.find({
where,
where: await this.scope.filter(where),
relations: ['matchedStudent'],
order: { attendanceDate: 'DESC', checkInTime: 'ASC' },
});
@@ -225,6 +244,28 @@ export class AttendanceService {
return this.dingRawRepo.save(record);
}
// ── Auto-match unmatched dingtalk records by phone/idCard/name ──
async autoMatchDingRecords(): Promise<{ matched: number; total: number }> {
const unmatched = await this.dingRawRepo.find({
where: { matchStatus: '未处理' },
});
if (unmatched.length === 0) return { matched: 0, total: 0 };
let matched = 0;
for (const record of unmatched) {
const student = await this.studentRepo.findOne({ where: { name: record.userName } });
if (student) {
record.matchStatus = '已匹配';
record.matchedStudentId = student.id;
await this.dingRawRepo.save(record);
matched++;
}
}
return { matched, total: unmatched.length };
}
// ── Export all attendance records with filters (no pagination) ──
async findAllForExport(query: {
classId?: number;
@@ -234,11 +275,14 @@ export class AttendanceService {
status?: string;
source?: string;
}) {
const qb = this.attendanceRepo
.createQueryBuilder('ar')
.leftJoinAndSelect('ar.student', 'student')
.leftJoinAndSelect('ar.class', 'class');
const qb = this.attendanceRepo.createQueryBuilder('ar');
const scopeIds = await this.scope.getScopeDepartmentIds();
if (scopeIds) {
qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
}
qb.leftJoinAndSelect('ar.student', 'student')
.leftJoinAndSelect('ar.class', 'class');
if (query.classId) {
qb.andWhere('ar.classId = :classId', { classId: query.classId });
}
@@ -262,4 +306,117 @@ export class AttendanceService {
return qb.getMany();
}
}
// ── Class-based attendance report ──
async getReport(query: AttendanceReportQueryDto) {
const qb = this.attendanceRepo.createQueryBuilder('ar');
const scopeIds = await this.scope.getScopeDepartmentIds();
if (scopeIds) {
qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
}
qb.leftJoin('ar.class', 'class')
.select('class.id', 'classId')
.addSelect('class.name', 'className')
.addSelect('ar.status', 'status')
.addSelect('COUNT(*)', 'count');
if (query.classId) {
qb.andWhere('ar.classId = :classId', { classId: query.classId });
}
if (query.dateFrom) {
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
}
if (query.dateTo) {
qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo });
}
qb.groupBy('class.id').addGroupBy('class.name').addGroupBy('ar.status');
const rawRows = await qb.getRawMany();
// Aggregate by class
const classMap = new Map<number, {
classId: number;
className: string;
present: number;
absent: number;
late: number;
leave: number;
}>();
for (const row of rawRows) {
if (!row.classId) continue;
if (!classMap.has(row.classId)) {
classMap.set(row.classId, {
classId: row.classId,
className: row.className || `班级#${row.classId}`,
present: 0,
absent: 0,
late: 0,
leave: 0,
});
}
const entry = classMap.get(row.classId)!;
const count = parseInt(row.count, 10);
if (row.status === 'present') entry.present += count;
else if (row.status === 'absent') entry.absent += count;
else if (row.status === 'late') entry.late += count;
else if (row.status === 'leave') entry.leave += count;
}
return Array.from(classMap.values()).map((entry) => {
const total = entry.present + entry.absent + entry.late + entry.leave;
return {
...entry,
total,
presentRate: total > 0 ? ((entry.present / total) * 100).toFixed(1) : '0.0',
absentRate: total > 0 ? ((entry.absent / total) * 100).toFixed(1) : '0.0',
lateRate: total > 0 ? ((entry.late / total) * 100).toFixed(1) : '0.0',
leaveRate: total > 0 ? ((entry.leave / total) * 100).toFixed(1) : '0.0',
};
});
}
// ── Attendance alerts: detect consecutive absences/late ──
async getAlerts(days: number = 14, threshold: number = 3) {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - days);
const cutoffStr = cutoff.toISOString().slice(0, 10);
const qb = this.attendanceRepo
.createQueryBuilder('a')
.leftJoinAndSelect('a.student', 'student')
.leftJoinAndSelect('a.class', 'class');
const scopeIds = await this.scope.getScopeDepartmentIds();
if (scopeIds) {
qb.andWhere('a.departmentId IN (:...scopeIds)', { scopeIds });
}
const records = await qb
.where('a.attendanceDate >= :cutoff', { cutoff: cutoffStr })
.andWhere('a.status IN (:...statuses)', { statuses: ['absent', 'late'] })
.orderBy('a.studentId', 'ASC')
.addOrderBy('a.attendanceDate', 'DESC')
.getMany();
const alerts: Array<{
studentId: number; studentName: string; className: string;
type: string; count: number; lastDate: string;
}> = [];
let current: typeof alerts[0] | null = null;
for (const r of records) {
const name = (r.student as any)?.name || '';
const className = (r.class as any)?.name || '';
const status = r.status === 'absent' ? '缺勤' : '迟到';
if (current && current.studentId === r.studentId && current.type === status) {
current.count++;
if (r.attendanceDate > current.lastDate) current.lastDate = r.attendanceDate;
} else {
if (current && current.count >= threshold) alerts.push({ ...current });
current = { studentId: r.studentId, studentName: name, className, type: status, count: 1, lastDate: r.attendanceDate };
}
}
if (current && current.count >= threshold) alerts.push(current);
return alerts;
}
}

View File

@@ -125,3 +125,18 @@ export class MatchDingRecordDto {
@IsNotEmpty()
studentId: number;
}
export class AttendanceReportQueryDto {
@IsOptional()
@IsInt()
@Type(() => Number)
classId?: number;
@IsOptional()
@IsDateString()
dateFrom?: string;
@IsOptional()
@IsDateString()
dateTo?: string;
}

View File

@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { NotificationsModule } from '../notifications/notifications.module';
import { DepartmentsModule } from '../departments/departments.module';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Bill } from '../entities/bill.entity';
import { BillItem } from '../entities/bill-item.entity';
@@ -26,6 +27,7 @@ import { BillsController } from './bills.controller';
Student,
]),
NotificationsModule,
DepartmentsModule,
],
controllers: [BillsController],
providers: [BillsService, BillsExportService],

View File

@@ -9,6 +9,7 @@ import { Occupancy } from '../entities/occupancy.entity';
import { Room } from '../entities/room.entity';
import { Deposit } from '../entities/deposit.entity';
import { GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto';
import { CampusScope } from '../common/campus-scope';
@Injectable()
export class BillsService {
@@ -21,6 +22,7 @@ export class BillsService {
@InjectRepository(Room) private roomRepo: Repository<Room>,
@InjectRepository(Deposit) private depositRepo: Repository<Deposit>,
private dataSource: DataSource,
private readonly scope: CampusScope,
) {}
/**
@@ -216,10 +218,12 @@ export class BillsService {
studentId?: number;
status?: string;
}) {
const scopeIds = await this.scope.getScopeDepartmentIds();
const qb = this.billRepo
.createQueryBuilder('b')
.leftJoinAndSelect('b.student', 'student')
.orderBy('b.generatedAt', 'DESC');
if (scopeIds) qb.andWhere('b.departmentId IN (:...scopeIds)', { scopeIds });
if (query?.periodStart) qb.andWhere('b.periodStart = :ps', { ps: query.periodStart });
if (query?.periodEnd) qb.andWhere('b.periodEnd = :pe', { pe: query.periodEnd });
if (query?.studentId) qb.andWhere('b.studentId = :sid', { sid: query.studentId });

View File

@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DepartmentsModule } from '../departments/departments.module';
import { Class, ClassStudent, ClassTeacher } from '../entities';
import { ClassesService } from './classes.service';
import { ClassesController } from './classes.controller';
@@ -7,7 +8,7 @@ import { OperationLogsModule } from '../operation-logs/operation-logs.module';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher]), OperationLogsModule, NotificationsModule],
imports: [TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher]), OperationLogsModule, NotificationsModule, DepartmentsModule],
controllers: [ClassesController],
providers: [ClassesService],
exports: [ClassesService],

View File

@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In, Like } from 'typeorm';
import { Class, ClassStudent, ClassTeacher } from '../entities';
import { CreateClassDto, UpdateClassDto, QueryClassDto, AddTeacherDto } from './dto/class.dto';
import { CampusScope } from '../common/campus-scope';
interface RawStudentCount {
classId: string;
@@ -18,14 +19,16 @@ export class ClassesService {
private classStudentRepo: Repository<ClassStudent>,
@InjectRepository(ClassTeacher)
private classTeacherRepo: Repository<ClassTeacher>,
private readonly scope: CampusScope,
) {}
async findAll(query: QueryClassDto) {
const where: Record<string, unknown> = {};
let where: Record<string, unknown> = {};
if (query.departmentId) where.departmentId = query.departmentId;
if (query.status) where.status = query.status;
if (query.classType) where.classType = query.classType;
if (query.keyword) where.name = Like(`%${query.keyword}%`);
where = await this.scope.filter(where);
const classes = await this.classRepo.find({
where,

View File

@@ -3,12 +3,14 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { ClassroomRental } from '../entities/classroom-rental.entity';
import { Classroom } from '../entities/classroom.entity';
import { Tenant } from '../entities/tenant.entity';
import { ClassSchedule } from '../entities/class-schedule.entity';
import { ClassroomRentalsService } from './classroom-rentals.service';
import { ClassroomRentalsController } from './classroom-rentals.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
import { DepartmentsModule } from '../departments/departments.module';
@Module({
imports: [TypeOrmModule.forFeature([ClassroomRental, Classroom, Tenant]), OperationLogsModule],
imports: [TypeOrmModule.forFeature([ClassroomRental, Classroom, Tenant, ClassSchedule]), OperationLogsModule, DepartmentsModule],
controllers: [ClassroomRentalsController],
providers: [ClassroomRentalsService],
exports: [ClassroomRentalsService],

View File

@@ -9,7 +9,9 @@ import { Repository, Not } from 'typeorm';
import { ClassroomRental } from '../entities/classroom-rental.entity';
import { Classroom } from '../entities/classroom.entity';
import { Tenant } from '../entities/tenant.entity';
import { ClassSchedule } from '../entities/class-schedule.entity';
import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto';
import { CampusScope } from '../common/campus-scope';
import * as path from 'path';
import * as fs from 'fs';
@@ -29,10 +31,13 @@ const COLOR_PALETTE = [
@Injectable()
export class ClassroomRentalsService {
constructor(
@InjectRepository(ClassroomRental) private repo: Repository<ClassroomRental>,
@InjectRepository(Classroom) private classroomRepo: Repository<Classroom>,
@InjectRepository(Tenant) private tenantRepo: Repository<Tenant>,
@InjectRepository(ClassSchedule) private scheduleRepo: Repository<ClassSchedule>,
private readonly scope: CampusScope,
) {}
get uploadDir(): string {
@@ -52,15 +57,16 @@ export class ClassroomRentalsService {
month?: string;
includeEnded?: boolean;
}) {
const scopeIds = await this.scope.getScopeDepartmentIds();
const qb = this.repo
.createQueryBuilder('r')
.leftJoinAndSelect('r.classroom', 'classroom')
.leftJoinAndSelect('r.tenant', 'tenant')
.orderBy('r.startDate', 'DESC');
if (scopeIds) qb.andWhere('r.departmentId IN (:...scopeIds)', { scopeIds });
if (query?.classroomId) qb.andWhere('r.classroomId = :cid', { cid: query.classroomId });
if (query?.tenantId) qb.andWhere('r.tenantId = :tid', { tid: query.tenantId });
if (query?.month) {
// month 格式 2026-06查询当月有重叠的租赁
const [y, m] = query.month.split('-').map(Number);
const first = `${y}-${String(m).padStart(2, '0')}-01`;
const lastDay = new Date(y, m, 0).getDate();
@@ -270,6 +276,7 @@ export class ClassroomRentalsService {
const day = d.getDate();
if (!matrix[rental.classroomId]) continue;
matrix[rental.classroomId][day] = {
scheduleType: 'RENTAL',
rentalId: rental.id,
tenantId: rental.tenantId,
tenantName: rental.tenant?.name || '未知',
@@ -280,6 +287,37 @@ export class ClassroomRentalsService {
}
}
// ── Overlay internal class schedules ──
const schedules = await this.scheduleRepo
.createQueryBuilder('s')
.leftJoinAndSelect('s.class', 'class')
.leftJoinAndSelect('s.teacher', 'teacher')
.where('s.status = :active', { active: 'active' })
.andWhere('s.scheduleType = :type', { type: 'INTERNAL' })
.andWhere('s.startDate <= :last AND s.endDate >= :first', { first, last })
.getMany();
for (const sched of schedules) {
if (!sched.classroomId) continue;
const schedStart = new Date(Math.max(new Date(sched.startDate).getTime(), new Date(first).getTime()));
const schedEnd = new Date(Math.min(new Date(sched.endDate).getTime(), new Date(last).getTime()));
for (let d = new Date(schedStart); d <= schedEnd; d.setDate(d.getDate() + 1)) {
const dow = d.getDay() === 0 ? 7 : d.getDay();
if (dow !== sched.weekDay) continue;
const day = d.getDate();
if (!matrix[sched.classroomId]) continue;
matrix[sched.classroomId][day] = {
scheduleType: 'INTERNAL',
scheduleId: sched.id,
className: (sched.class as any)?.name || '',
subject: sched.subject,
teacherName: (sched.teacher as any)?.name || '',
startTime: sched.startTime,
endTime: sched.endTime,
color: '#52c41a',
};
}
}
// 统计
for (const cls of classrooms) {
const rented = Object.keys(matrix[cls.id]).length;

View File

@@ -223,4 +223,36 @@ export class ClassroomsController {
});
return result;
}
}
@Get('report')
@RequirePermission('classroom:view')
async exportReport(
@Query('dateFrom') dateFrom: string,
@Query('dateTo') dateTo: string,
@Res() res: Response,
) {
const data = await this.service.getUsageReport(dateFrom, dateTo);
const workbook = new ExcelJS.Workbook();
const ws = workbook.addWorksheet('教室使用统计');
ws.columns = [
{ header: '教室名称', key: 'name', width: 20 },
{ header: '楼栋', key: 'building', width: 12 },
{ header: '类型', key: 'roomType', width: 12 },
{ header: '容量', key: 'capacity', width: 8 },
{ header: '统计天数', key: 'totalDays', width: 10 },
{ header: '租赁占用天数', key: 'rentalDays', width: 14 },
{ header: '排课占用天数', key: 'scheduleDays', width: 14 },
{ header: '空闲天数', key: 'idleDays', width: 10 },
{ header: '占用率', key: 'occupancyRate', width: 10 },
];
ws.getRow(1).font = { bold: true };
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
for (const row of data) {
ws.addRow({ ...row, occupancyRate: `${row.occupancyRate}%` });
}
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', 'attachment; filename=classroom-report.xlsx');
await workbook.xlsx.write(res);
res.end();
}
}

View File

@@ -2,12 +2,14 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Classroom } from '../entities/classroom.entity';
import { ClassroomRental } from '../entities/classroom-rental.entity';
import { ClassSchedule } from '../entities/class-schedule.entity';
import { ClassroomsService } from './classrooms.service';
import { ClassroomsController } from './classrooms.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
import { DepartmentsModule } from '../departments/departments.module';
@Module({
imports: [TypeOrmModule.forFeature([Classroom, ClassroomRental]), OperationLogsModule],
imports: [TypeOrmModule.forFeature([Classroom, ClassroomRental, ClassSchedule]), OperationLogsModule, DepartmentsModule],
controllers: [ClassroomsController],
providers: [ClassroomsService],
exports: [ClassroomsService],

View File

@@ -3,21 +3,26 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Not } from 'typeorm';
import { Classroom } from '../entities/classroom.entity';
import { ClassroomRental } from '../entities/classroom-rental.entity';
import { ClassSchedule } from '../entities/class-schedule.entity';
import { CampusScope } from '../common/campus-scope';
import { CreateClassroomDto, UpdateClassroomDto } from './dto/classroom.dto';
@Injectable()
export class ClassroomsService {
constructor(
@InjectRepository(Classroom) private repo: Repository<Classroom>,
@InjectRepository(ClassroomRental) private rentalRepo: Repository<ClassroomRental>,
@InjectRepository(ClassSchedule) private scheduleRepo: Repository<ClassSchedule>,
private readonly scope: CampusScope,
) {}
async findAll(query?: { building?: string; roomType?: string; includeArchived?: boolean }) {
const where: any = {};
const where: Record<string, unknown> = {};
if (query?.building) where.building = query.building;
if (query?.roomType) where.roomType = query.roomType;
if (!query?.includeArchived) where.status = Not('archived');
return this.repo.find({ where, order: { building: 'ASC', name: 'ASC' } });
return this.repo.find({ where: await this.scope.filter(where), order: { building: 'ASC', name: 'ASC' } });
}
async findOne(id: number) {
@@ -40,18 +45,14 @@ export class ClassroomsService {
async remove(id: number) {
await this.findOne(id);
// 若存在未结束的租赁订单,不允许归档
const active = await this.rentalRepo.count({ where: { classroomId: id, status: 'active' } });
if (active > 0) throw new BadRequestException('该教室存在进行中的租赁订单,无法归档');
await this.repo.update(id, { status: 'archived' });
await this.repo.update(id, { status: 'archived' } as any);
return { message: '已归档' };
}
async restore(id: number) {
const cls = await this.findOne(id);
if (cls.status !== 'archived') throw new BadRequestException('该教室未被归档');
await this.repo.update(id, { status: 'available' });
return { message: '已恢复' };
await this.findOne(id);
await this.repo.update(id, { status: 'available' } as any);
return this.repo.findOne({ where: { id } });
}
async batchImport(
@@ -62,39 +63,82 @@ export class ClassroomsService {
capacity?: number;
roomType?: string;
courseType?: string;
supervisor?: string;
}[],
) {
let imported = 0;
let skipped = 0;
const errors: string[] = [];
for (const row of rows) {
if (!row.name || !row.name.trim()) {
skipped++;
continue;
}
const name = row.name.trim();
const exists = await this.repo.findOne({ where: { name } });
if (exists) {
skipped++;
continue;
}
await this.repo.save(
this.repo.create({
name,
building: row.building?.trim() || undefined,
floor: row.floor || undefined,
capacity: row.capacity || 30,
roomType: row.roomType?.trim() || '大',
courseType: row.courseType?.trim() || undefined,
supervisor: row.supervisor?.trim() || undefined,
}),
);
if (!row.name?.trim()) { skipped++; continue; }
const exists = await this.repo.findOne({ where: { name: row.name.trim() } });
if (exists) { errors.push(`教室 ${row.name} 已存在`); skipped++; continue; }
await this.repo.save(this.repo.create({ ...row, capacity: row.capacity || 30 }));
imported++;
}
return {
message: `成功导入 ${imported} 间教室,跳过 ${skipped} 条(重复或空行)`,
imported,
skipped,
};
return { message: `成功导入 ${imported} 间教室,跳过 ${skipped}`, imported, skipped, errors: errors.length > 0 ? errors : undefined };
}
async getUsageReport(dateFrom: string, dateTo: string) {
const classrooms = await this.repo.find({
where: { status: Not('archived') },
order: { building: 'ASC', name: 'ASC' },
});
const rentals = await this.rentalRepo
.createQueryBuilder('r')
.where('r.status != :cancelled', { cancelled: 'cancelled' })
.andWhere('r.startDate <= :dateTo AND r.endDate >= :dateFrom', { dateFrom, dateTo })
.getMany();
const schedules = await this.scheduleRepo
.createQueryBuilder('s')
.where('s.status = :active', { active: 'active' })
.andWhere('s.scheduleType = :type', { type: 'INTERNAL' })
.andWhere('s.startDate <= :dateTo AND s.endDate >= :dateFrom', { dateFrom, dateTo })
.getMany();
const start = new Date(dateFrom);
const end = new Date(dateTo);
const totalDays = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1;
const rentalDaysByRoom: Record<number, Set<string>> = {};
const scheduleDaysByRoom: Record<number, Set<string>> = {};
for (const r of rentals) {
if (!rentalDaysByRoom[r.classroomId]) rentalDaysByRoom[r.classroomId] = new Set();
const effStart = new Date(Math.max(new Date(r.startDate).getTime(), start.getTime()));
const effEnd = new Date(Math.min(new Date(r.endDate).getTime(), end.getTime()));
for (let d = new Date(effStart); d <= effEnd; d.setDate(d.getDate() + 1)) {
rentalDaysByRoom[r.classroomId].add(d.toISOString().slice(0, 10));
}
}
for (const s of schedules) {
if (!scheduleDaysByRoom[s.classroomId]) scheduleDaysByRoom[s.classroomId] = new Set();
const effStart = new Date(Math.max(new Date(s.startDate).getTime(), start.getTime()));
const effEnd = new Date(Math.min(new Date(s.endDate).getTime(), end.getTime()));
for (let d = new Date(effStart); d <= effEnd; d.setDate(d.getDate() + 1)) {
scheduleDaysByRoom[s.classroomId].add(d.toISOString().slice(0, 10));
}
}
return classrooms.map((c) => {
const rentalDays = rentalDaysByRoom[c.id]?.size || 0;
const scheduleDays = scheduleDaysByRoom[c.id]?.size || 0;
const usedDays = rentalDays + scheduleDays;
return {
id: c.id,
name: c.name,
building: c.building || '',
roomType: c.roomType || '',
capacity: c.capacity,
totalDays,
rentalDays,
scheduleDays,
usedDays,
idleDays: totalDays - usedDays,
occupancyRate: totalDays > 0 ? ((usedDays / totalDays) * 100).toFixed(1) : '0.0',
};
});
}
}

View File

@@ -49,6 +49,13 @@ export class CampusScope {
return { ...where, departmentId: In(ids) } as unknown as T;
}
/** Returns department IDs for QueryBuilder .andWhere() usage. null = no filtering needed. */
async getScopeDepartmentIds(): Promise<number[] | null> {
if (this.isSuperAdmin && !this.currentDepartmentId) return null;
const ids = await this.getEffectiveScopeIds();
return ids.length > 0 ? ids : null;
}
private async getEffectiveScopeIds(): Promise<number[]> {
// Specific campus selected → campus + descendants
if (this.currentDepartmentId) {

View File

@@ -38,4 +38,14 @@ export class DashboardController {
) {
return this.service.getRoomExpenseRanking(periodStart, periodEnd);
}
}
@Get('class-attendance-ranking')
getClassAttendanceRanking() {
return this.service.getClassAttendanceRanking();
}
@Get('classroom-occupancy')
getClassroomOccupancy() {
return this.service.getClassroomOccupancy();
}
}

View File

@@ -13,10 +13,11 @@ import { Deposit } from '../entities/deposit.entity';
import { ClassroomRental } from '../entities/classroom-rental.entity';
import { ClassTeacher } from '../entities/class-teacher.entity';
import { DashboardService } from './dashboard.service';
import { DepartmentsModule } from '../departments/departments.module';
import { DashboardController } from './dashboard.controller';
@Module({
imports: [TypeOrmModule.forFeature([Room, Student, Occupancy, Bill, RoomExpense, Classroom, ClassSchedule, AttendanceRecord, Class, Deposit, ClassroomRental, ClassTeacher])],
imports: [TypeOrmModule.forFeature([Room, Student, Occupancy, Bill, RoomExpense, Classroom, ClassSchedule, AttendanceRecord, Class, Deposit, ClassroomRental, ClassTeacher]), DepartmentsModule],
controllers: [DashboardController],
providers: [DashboardService],
})

View File

@@ -13,9 +13,11 @@ 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<Room>,
@InjectRepository(Student) private studentRepo: Repository<Student>,
@@ -29,54 +31,60 @@ export class DashboardService {
@InjectRepository(Deposit) private depositRepo: Repository<Deposit>,
@InjectRepository(ClassroomRental) private rentalRepo: Repository<ClassroomRental>,
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
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: { 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
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' })
.getRawOne();
.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 billStats = await this.billRepo
const billStatsQb = this.billRepo
.createQueryBuilder('b')
.select('b.status', 'status')
.addSelect('COUNT(*)', 'count')
.addSelect('SUM(b.totalAmount)', 'total')
.groupBy('b.status')
.getRawMany();
.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();
const classroomCount = await this.classroomRepo.count({ where: await this.scope.filter({}) });
const occResult = await this.scheduleRepo
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 })
.getRawOne();
.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 attTodayStats = await this.attendanceRepo
const attTodayQb = this.attendanceRepo
.createQueryBuilder('a')
.select('a.status', 'status')
.addSelect('COUNT(*)', 'count')
.where('a.attendanceDate = :today', { today: todayStr })
.groupBy('a.status')
.getRawMany();
.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')
@@ -85,59 +93,62 @@ export class DashboardService {
? ((todayPresent / todayTotal) * 100).toFixed(1)
: 0;
const incomeResult = await this.billRepo
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) })
.getRawOne();
.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();
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 pendingResult = await this.depositRepo
const pendingQb = this.depositRepo
.createQueryBuilder('d')
.select('SUM(d.amount)', 'total')
.where('d.status = :paid', { paid: 'paid' })
.andWhere('d.refundStatus IS NULL')
.getRawOne();
.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: { endDate: MoreThanOrEqual(todayStr) } });
const activeRentals = await this.rentalRepo.count({ where: await this.scope.filter({ endDate: MoreThanOrEqual(todayStr) }) });
const occupancyByBuilding = await this.occRepo
const occByBldQb = this.occRepo
.createQueryBuilder('o')
.leftJoin('o.room', 'r')
.select('r.building', 'building')
.addSelect('COUNT(*)', 'count')
.where('o.checkOutDate IS NULL')
.groupBy('r.building')
.getRawMany();
.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<string, number>);
const expenseByType = await this.expRepo
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) })
.groupBy('e.expenseType')
.getRawMany();
.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,
@@ -227,6 +238,7 @@ export class DashboardService {
// 甘特图数据:每个宿舍的入住时间线
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')
@@ -235,6 +247,8 @@ export class DashboardService {
.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 });
}
@@ -248,7 +262,7 @@ export class DashboardService {
const records = await qb.getMany();
// 按宿舍分组
const roomMap = new Map<string, any[]>();
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, []);
@@ -267,14 +281,15 @@ export class DashboardService {
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();
@@ -282,6 +297,7 @@ export class DashboardService {
// 各宿舍费用排行
async getRoomExpenseRanking(periodStart?: string, periodEnd?: string) {
const scopeIds = await this.scope.getScopeDepartmentIds();
const qb = this.expRepo
.createQueryBuilder('e')
.leftJoin('e.room', 'room')
@@ -291,8 +307,80 @@ export class DashboardService {
.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<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 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<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),
}));
}
}

View File

@@ -4,12 +4,13 @@ import { Student } from '../entities/student.entity';
import { Deposit } from '../entities/deposit.entity';
import { DepositInstallment } from '../entities/deposit-installment.entity';
import { DepositsService } from './deposits.service';
import { DepartmentsModule } from '../departments/departments.module';
import { DepositsController } from './deposits.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [TypeOrmModule.forFeature([Deposit, DepositInstallment, Student]), OperationLogsModule, NotificationsModule],
imports: [TypeOrmModule.forFeature([Deposit, DepositInstallment, Student]), OperationLogsModule, NotificationsModule, DepartmentsModule],
controllers: [DepositsController],
providers: [DepositsService],
exports: [DepositsService],

View File

@@ -3,22 +3,27 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Deposit } from '../entities/deposit.entity';
import { DepositInstallment } from '../entities/deposit-installment.entity';
import { CampusScope } from '../common/campus-scope';
import { CreateDepositDto, RefundDepositDto, CreateDepositWithInstallmentsDto } from './dto/deposit.dto';
@Injectable()
export class DepositsService {
constructor(
@InjectRepository(Deposit) private repo: Repository<Deposit>,
@InjectRepository(DepositInstallment)
private installmentRepo: Repository<DepositInstallment>,
private readonly scope: CampusScope,
) {}
async findAll(query?: { studentId?: number; status?: string }) {
const scopeIds = await this.scope.getScopeDepartmentIds();
const qb = this.repo
.createQueryBuilder('d')
.leftJoinAndSelect('d.student', 'student')
.leftJoinAndSelect('d.installments', 'installments')
.orderBy('d.createdAt', 'DESC');
if (scopeIds) qb.andWhere('d.departmentId IN (:...scopeIds)', { scopeIds });
if (query?.studentId) qb.andWhere('d.studentId = :studentId', { studentId: query.studentId });
if (query?.status) qb.andWhere('d.status = :status', { status: query.status });
return qb.getMany();
@@ -145,12 +150,12 @@ export class DepositsService {
return this.repo.save(deposit);
}
async findPendingRefunds() {
const baseWhere = await this.scope.filter({});
return this.repo.find({
where: [
{ refundStatus: 'pending' },
{ refundStatus: 'head_teacher_approved' },
{ ...baseWhere, refundStatus: 'pending' },
{ ...baseWhere, refundStatus: 'head_teacher_approved' },
],
relations: ['student', 'installments'],
order: { refundRequestedAt: 'DESC' },
@@ -165,13 +170,14 @@ export class DepositsService {
}
async getStats() {
const result = await this.repo
const scopeIds = await this.scope.getScopeDepartmentIds();
const qb = this.repo
.createQueryBuilder('d')
.select('d.status', 'status')
.addSelect('COUNT(*)', 'count')
.addSelect('SUM(d.amount)', 'totalAmount')
.groupBy('d.status')
.getRawMany();
return result;
.addSelect('SUM(d.amount)', 'totalAmount');
if (scopeIds) qb.andWhere('d.departmentId IN (:...scopeIds)', { scopeIds });
qb.groupBy('d.status');
return qb.getRawMany();
}
}

View File

@@ -0,0 +1,35 @@
import { IsString, IsOptional, IsInt, IsBoolean, IsIn } from 'class-validator';
export class CreateExpenseTypeDto {
@IsString()
code: string;
@IsString()
name: string;
@IsOptional()
@IsIn(['room', 'personal', 'both'])
category?: string;
@IsOptional()
@IsInt()
sortOrder?: number;
}
export class UpdateExpenseTypeDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsIn(['room', 'personal', 'both'])
category?: string;
@IsOptional()
@IsInt()
sortOrder?: number;
@IsOptional()
@IsBoolean()
enabled?: boolean;
}

View File

@@ -0,0 +1,84 @@
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards, Request } from '@nestjs/common';
import { ExpenseTypesService } from './expense-types.service';
import { CreateExpenseTypeDto, UpdateExpenseTypeDto } from './dto/expense-type.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils';
import { RequirePermission } from '../auth/decorators/permission.decorator';
@UseGuards(JwtAuthGuard)
@Controller('expense-types')
export class ExpenseTypesController {
constructor(
private readonly service: ExpenseTypesService,
private readonly logService: OperationLogsService,
) {}
@Get()
@RequirePermission('expense:view')
findAll() {
return this.service.findAll();
}
@Get('admin')
@RequirePermission('expense:edit')
findAllAdmin() {
return this.service.findAllAdmin();
}
@Post()
@RequirePermission('expense:create')
async create(@Body() dto: CreateExpenseTypeDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.create(dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用类型',
action: '新增费用类型',
targetId: result.id,
targetType: 'expense_type',
detail: `${dto.code} - ${dto.name}`,
ipAddress,
userAgent,
});
return result;
}
@Put(':id')
@RequirePermission('expense:edit')
async update(@Param('id') id: string, @Body() dto: UpdateExpenseTypeDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.update(+id, dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用类型',
action: '编辑费用类型',
targetId: +id,
targetType: 'expense_type',
detail: JSON.stringify(dto),
ipAddress,
userAgent,
});
return result;
}
@Delete(':id')
@RequirePermission('expense:delete')
async remove(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
await this.service.remove(+id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用类型',
action: '删除费用类型',
targetId: +id,
targetType: 'expense_type',
ipAddress,
userAgent,
});
return { message: '已删除' };
}
}

View File

@@ -0,0 +1,20 @@
import { Module, OnModuleInit } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ExpenseType } from '../entities/expense-type.entity';
import { ExpenseTypesService } from './expense-types.service';
import { ExpenseTypesController } from './expense-types.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
@Module({
imports: [TypeOrmModule.forFeature([ExpenseType]), OperationLogsModule],
controllers: [ExpenseTypesController],
providers: [ExpenseTypesService],
exports: [ExpenseTypesService],
})
export class ExpenseTypesModule implements OnModuleInit {
constructor(private readonly service: ExpenseTypesService) {}
async onModuleInit() {
await this.service.seedDefaults();
}
}

View File

@@ -0,0 +1,70 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ExpenseType } from '../entities/expense-type.entity';
import { CreateExpenseTypeDto, UpdateExpenseTypeDto } from './dto/expense-type.dto';
const DEFAULT_TYPES = [
{ code: 'water', name: '水费', category: 'room', sortOrder: 1 },
{ code: 'electricity', name: '电费', category: 'room', sortOrder: 2 },
{ code: 'cleaning', name: '保洁费', category: 'room', sortOrder: 3 },
{ code: 'damage', name: '损坏赔偿', category: 'both', sortOrder: 4 },
{ code: 'penalty', name: '罚款', category: 'personal', sortOrder: 5 },
{ code: 'key', name: '钥匙费', category: 'personal', sortOrder: 6 },
{ code: 'remote', name: '空调遥控器', category: 'personal', sortOrder: 7 },
{ code: 'deposit_deduction', name: '押金扣除', category: 'personal', sortOrder: 8 },
{ code: 'other', name: '其他', category: 'both', sortOrder: 99 },
];
@Injectable()
export class ExpenseTypesService {
constructor(
@InjectRepository(ExpenseType)
private repo: Repository<ExpenseType>,
) {}
async seedDefaults(): Promise<void> {
for (const t of DEFAULT_TYPES) {
const exists = await this.repo.findOne({ where: { code: t.code } });
if (!exists) {
await this.repo.save(this.repo.create(t));
}
}
}
async findAll(): Promise<ExpenseType[]> {
return this.repo.find({ where: { enabled: true }, order: { sortOrder: 'ASC' } });
}
async findAllAdmin(): Promise<ExpenseType[]> {
return this.repo.find({ order: { sortOrder: 'ASC' } });
}
async findOne(id: number): Promise<ExpenseType> {
const t = await this.repo.findOne({ where: { id } });
if (!t) throw new NotFoundException('费用类型不存在');
return t;
}
async getValidCodes(): Promise<string[]> {
const types = await this.findAll();
return types.map((t) => t.code);
}
async create(dto: CreateExpenseTypeDto): Promise<ExpenseType> {
const exists = await this.repo.findOne({ where: { code: dto.code } });
if (exists) throw new ConflictException('费用类型代码已存在');
return this.repo.save(this.repo.create(dto));
}
async update(id: number, dto: UpdateExpenseTypeDto): Promise<ExpenseType> {
const t = await this.findOne(id);
Object.assign(t, dto);
return this.repo.save(t);
}
async remove(id: number): Promise<void> {
const t = await this.findOne(id);
await this.repo.remove(t);
}
}

View File

@@ -1,10 +1,10 @@
import { IsInt, IsString, IsNumber, IsOptional, IsEnum } from 'class-validator';
import { IsInt, IsString, IsNumber, IsOptional } from 'class-validator';
export class CreateRoomExpenseDto {
@IsInt()
roomId: number;
@IsEnum(['water', 'electricity', 'cleaning', 'damage', 'other'])
@IsString()
expenseType: string;
@IsNumber()
@@ -29,7 +29,7 @@ export class CreatePersonalExpenseDto {
@IsInt()
roomId?: number;
@IsEnum(['damage', 'cleaning', 'penalty', 'key', 'remote', 'deposit_deduction', 'other'])
@IsString()
expenseType: string;
@IsNumber()

View File

@@ -405,15 +405,6 @@ export class ExpensesController {
@RequirePermission('expense:view')
async exportPersonalExpenses(@Res() res: Response) {
const data = await this.service.findPersonalExpenses();
const typeMap: Record<string, string> = {
damage: '物品损坏',
cleaning: '保洁费',
penalty: '罚款',
key: '钥匙费',
remote: '空调遥控器',
deposit_deduction: '押金扣除',
other: '其他',
};
const workbook = new ExcelJS.Workbook();
const ws = workbook.addWorksheet('个人附加费');
ws.columns = [
@@ -427,7 +418,7 @@ export class ExpensesController {
data.forEach((d: any) => {
ws.addRow({
studentName: d.student?.name || '',
expenseType: typeMap[d.expenseType] || d.expenseType,
expenseType: d.expenseType,
amount: Number(d.amount),
expenseDate: d.expenseDate,
description: d.description || '',

View File

@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DepartmentsModule } from '../departments/departments.module';
import { RoomExpense } from '../entities/room-expense.entity';
import { PersonalExpense } from '../entities/personal-expense.entity';
import { Room } from '../entities/room.entity';
@@ -12,6 +13,7 @@ import { OperationLogsModule } from '../operation-logs/operation-logs.module';
imports: [
TypeOrmModule.forFeature([RoomExpense, PersonalExpense, Room, Student]),
OperationLogsModule,
DepartmentsModule,
],
controllers: [ExpensesController],
providers: [ExpensesService],

View File

@@ -11,6 +11,7 @@ import {
BatchRoomExpenseDto,
} from './dto/expense.dto';
import { RoomsService } from '../rooms/rooms.service';
import { CampusScope } from '../common/campus-scope';
@Injectable()
export class ExpensesService {
@@ -19,6 +20,7 @@ export class ExpensesService {
@InjectRepository(PersonalExpense) private personalExpRepo: Repository<PersonalExpense>,
@InjectRepository(Room) private roomRepo: Repository<Room>,
@InjectRepository(Student) private studentRepo: Repository<Student>,
private readonly scope: CampusScope,
) {}
// 宿舍费用
@@ -42,10 +44,12 @@ export class ExpensesService {
}
async findRoomExpenses(query?: { roomId?: number; periodStart?: string; periodEnd?: string }) {
const scopeIds = await this.scope.getScopeDepartmentIds();
const qb = this.roomExpRepo
.createQueryBuilder('e')
.leftJoinAndSelect('e.room', 'room')
.orderBy('e.createdAt', 'DESC');
if (scopeIds) qb.andWhere('e.departmentId IN (:...scopeIds)', { scopeIds });
if (query?.roomId) qb.andWhere('e.roomId = :roomId', { roomId: query.roomId });
if (query?.periodStart) qb.andWhere('e.periodStart >= :ps', { ps: query.periodStart });
if (query?.periodEnd) qb.andWhere('e.periodEnd <= :pe', { pe: query.periodEnd });
@@ -82,8 +86,9 @@ export class ExpensesService {
}
async findPersonalExpenses(query?: { studentId?: number }) {
const where: any = {};
let where: Record<string, unknown> = {};
if (query?.studentId) where.studentId = query.studentId;
where = await this.scope.filter(where);
return this.personalExpRepo.find({
where,
relations: ['student'],
@@ -286,19 +291,6 @@ export class ExpensesService {
let skipped = 0;
const errors: string[] = [];
const typeMap: Record<string, string> = {
: 'damage',
: 'damage',
: 'cleaning',
: 'cleaning',
: 'penalty',
: 'key',
: 'key',
: 'remote',
: 'remote',
: 'deposit_deduction',
: 'other',
};
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
@@ -319,21 +311,9 @@ export class ExpensesService {
}
// 解析费用类型
let expenseType = row.expenseType?.trim() || '';
if (typeMap[expenseType]) {
expenseType = typeMap[expenseType];
}
const validTypes = [
'damage',
'cleaning',
'penalty',
'key',
'remote',
'deposit_deduction',
'other',
];
if (!validTypes.includes(expenseType)) {
errors.push(`${rowNum}行: 费用类型"${row.expenseType}"无效`);
const expenseType = row.expenseType?.trim() || '';
if (!expenseType) {
errors.push(`${rowNum}行: 费用类型不能为空`);
skipped++;
continue;
}

View File

@@ -8,9 +8,10 @@ import { OccupanciesService } from './occupancies.service';
import { OccupanciesController } from './occupancies.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { DepartmentsModule } from '../departments/departments.module';
@Module({
imports: [TypeOrmModule.forFeature([Occupancy, Room, Student, Deposit]), OperationLogsModule, NotificationsModule],
imports: [TypeOrmModule.forFeature([Occupancy, Room, Student, Deposit]), OperationLogsModule, NotificationsModule, DepartmentsModule],
controllers: [OccupanciesController],
providers: [OccupanciesService],
exports: [OccupanciesService],

View File

@@ -15,6 +15,7 @@ import { Student } from '../entities/student.entity';
import { Deposit } from '../entities/deposit.entity';
import { CheckInDto, CheckOutDto, TransferRoomDto } from './dto/occupancy.dto';
import { RoomsService } from '../rooms/rooms.service';
import { CampusScope } from '../common/campus-scope';
@Injectable()
export class OccupanciesService {
@@ -24,6 +25,7 @@ export class OccupanciesService {
@InjectRepository(Student) private studentRepo: Repository<Student>,
@InjectRepository(Deposit) private depositRepo: Repository<Deposit>,
private dataSource: DataSource,
private readonly scope: CampusScope,
) {}
async findAll(query?: { roomId?: number; studentId?: number; active?: boolean }) {
@@ -32,6 +34,10 @@ export class OccupanciesService {
.leftJoinAndSelect('o.student', 'student')
.leftJoinAndSelect('o.room', 'room')
.orderBy('o.checkInDate', 'DESC');
const scopeIds = await this.scope.getScopeDepartmentIds();
if (scopeIds) {
qb.andWhere('o.departmentId IN (:...scopeIds)', { scopeIds });
}
if (query?.roomId) qb.andWhere('o.roomId = :roomId', { roomId: query.roomId });
if (query?.studentId) qb.andWhere('o.studentId = :studentId', { studentId: query.studentId });
if (query?.active) qb.andWhere('o.checkOutDate IS NULL');

View File

@@ -1,15 +1,16 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { Controller, Get, Post, Body, Query, UseGuards, Request } from '@nestjs/common';
import { OperationLogsService } from './operation-logs.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import { extractRequestInfo } from '../common/request-utils';
@UseGuards(JwtAuthGuard)
@RequirePermission('log:view')
@Controller('operation-logs')
export class OperationLogsController {
constructor(private service: OperationLogsService) {}
@Get()
@RequirePermission('log:view')
findAll(
@Query('module') module?: string,
@Query('userId') userId?: string,
@@ -27,4 +28,23 @@ export class OperationLogsController {
pageSize: pageSize ? +pageSize : 50,
});
}
@Post('audit')
async createAuditLog(
@Body() body: { module: string; action: string; targetId?: number; targetType?: string; detail?: string },
@Request() req: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
return this.service.log({
userId: req.user?.id,
username: req.user?.username,
module: body.module,
action: body.action,
targetId: body.targetId,
targetType: body.targetType,
detail: body.detail,
ipAddress,
userAgent,
});
}
}

View File

@@ -68,6 +68,7 @@ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> =
{ code: 'attendance:view', name: '查看考勤', group: 'attendance' },
{ code: 'attendance:create', name: '新增考勤', group: 'attendance' },
{ code: 'attendance:edit', name: '编辑考勤', group: 'attendance' },
{ code: 'attendance:export', name: '导出考勤', group: 'attendance' },
];
const PRESET_ROLES: Array<{

View File

@@ -63,15 +63,17 @@ export class RoomsController {
{ header: '楼层', key: 'floor', width: 8 },
{ header: '额定人数', key: 'capacity', width: 10 },
{ header: '宿舍类型', key: 'roomType', width: 12 },
{ header: '租赁类型(long/short)', key: 'rentalCategory', width: 18 },
{ header: '月租金', key: 'monthlyRate', width: 10 },
];
ws.getRow(1).font = { bold: true };
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
ws.addRow({
roomNumber: '4-102',
building: '4号楼',
floor: 1,
capacity: 4,
roomType: '四人间',
rentalCategory: 'long',
monthlyRate: 800,
});
ws.addRow({
roomNumber: '2-201',
@@ -79,6 +81,8 @@ export class RoomsController {
floor: 2,
capacity: 1,
roomType: '单人间',
rentalCategory: 'short',
monthlyRate: 0,
});
res.setHeader(
'Content-Type',
@@ -106,6 +110,8 @@ export class RoomsController {
{ header: '当前入住', key: 'currentCount', width: 10 },
{ header: '性别', key: 'gender', width: 8 },
{ header: '状态', key: 'status', width: 10 },
{ header: '租赁类型', key: 'rentalCategory', width: 12 },
{ header: '月租金', key: 'monthlyRate', width: 10 },
];
ws.getRow(1).font = { bold: true };
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
@@ -125,6 +131,8 @@ export class RoomsController {
currentCount: r.currentCount,
gender: r.gender || '',
status: statusMap[r.status] || r.status,
rentalCategory: r.rentalCategory === 'long' ? '长租' : '短租',
monthlyRate: r.monthlyRate ?? '',
});
}
res!.setHeader(
@@ -245,15 +253,26 @@ export class RoomsController {
floor?: number;
capacity?: number;
roomType?: string;
rentalCategory?: string;
monthlyRate?: number;
}[] = [];
ws.eachRow((row, idx) => {
if (idx === 1) return;
const rentalCategoryRaw = String(row.getCell(6).value || '').trim().toLowerCase();
const rentalCategory =
rentalCategoryRaw === 'long' || rentalCategoryRaw === 'short'
? rentalCategoryRaw
: undefined;
const monthlyRateRaw = Number(row.getCell(7).value);
const monthlyRate = isNaN(monthlyRateRaw) ? undefined : monthlyRateRaw;
rows.push({
roomNumber: String(row.getCell(1).value || ''),
building: String(row.getCell(2).value || '') || undefined,
floor: Number(row.getCell(3).value) || undefined,
capacity: Number(row.getCell(4).value) || 4,
roomType: String(row.getCell(5).value || '').trim() || undefined,
rentalCategory,
monthlyRate,
});
});
const result = await this.service.batchImport(rows);

View File

@@ -6,9 +6,10 @@ import { RoomExpense } from '../entities/room-expense.entity';
import { RoomsService } from './rooms.service';
import { RoomsController } from './rooms.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
import { DepartmentsModule } from '../departments/departments.module';
@Module({
imports: [TypeOrmModule.forFeature([Room, Occupancy, RoomExpense]), OperationLogsModule],
imports: [TypeOrmModule.forFeature([Room, Occupancy, RoomExpense]), OperationLogsModule, DepartmentsModule],
controllers: [RoomsController],
providers: [RoomsService],
exports: [RoomsService],

View File

@@ -1,6 +1,7 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Like, IsNull, Not, In } from 'typeorm';
import { CampusScope } from '../common/campus-scope';
import { Room } from '../entities/room.entity';
import { Occupancy } from '../entities/occupancy.entity';
import { RoomExpense } from '../entities/room-expense.entity';
@@ -12,6 +13,7 @@ export class RoomsService {
@InjectRepository(Room) private repo: Repository<Room>,
@InjectRepository(Occupancy) private occRepo: Repository<Occupancy>,
@InjectRepository(RoomExpense) private roomExpRepo: Repository<RoomExpense>,
private readonly scope: CampusScope,
) {}
/**
@@ -62,7 +64,8 @@ export class RoomsService {
const where: any = {};
if (query?.building) where.building = query.building;
if (!query?.includeArchived) where.status = Not('archived');
return this.repo.find({ where, order: { roomNumber: 'ASC' } });
const filteredWhere = await this.scope.filter(where);
return this.repo.find({ where: filteredWhere, order: { roomNumber: 'ASC' } });
}
async findOne(id: number) {
@@ -84,7 +87,8 @@ export class RoomsService {
async getRoomOverview(query?: { includeArchived?: boolean }) {
const where: any = {};
if (!query?.includeArchived) where.status = Not('archived');
const rooms = await this.repo.find({ where, order: { building: 'ASC', roomNumber: 'ASC' } });
const filteredWhere = await this.scope.filter(where);
const rooms = await this.repo.find({ where: filteredWhere, order: { building: 'ASC', roomNumber: 'ASC' } });
const result: any[] = [];
for (const room of rooms) {
const count = await this.occRepo.count({
@@ -161,12 +165,12 @@ export class RoomsService {
async getRoomVisual() {
const rooms = await this.repo.find({
where: { status: Not('archived') },
where: await this.scope.filter({ status: Not('archived') }),
order: { building: 'ASC', roomNumber: 'ASC' },
});
const occupancies = await this.occRepo.find({
where: { checkOutDate: IsNull() },
relations: ['student'],
where: await this.scope.filter({ checkOutDate: IsNull() }),
relations: ['student', 'tenant'],
order: { checkInDate: 'ASC' },
});
@@ -188,6 +192,8 @@ export class RoomsService {
days,
organization: occ.student?.organization || null,
supervisor: occ.student?.supervisor || null,
tenantName: occ.tenant?.name || null,
tenantColor: occ.tenant?.color || null,
});
}
@@ -209,6 +215,9 @@ export class RoomsService {
orgLabel = `存在${orgs.join('、')}人员`;
}
}
// 计算租户颜色:所有住户同一租户则使用该颜色
const tenantColors = [...new Set(occ.map((o: any) => o.tenantColor).filter(Boolean))];
const tenantColor: string | null = tenantColors.length === 1 ? tenantColors[0] : null;
return {
id: room.id,
roomNumber: room.roomNumber,
@@ -219,6 +228,7 @@ export class RoomsService {
currentCount: occ.length,
occupants: occ,
orgLabel,
tenantColor,
};
}),
};
@@ -231,6 +241,8 @@ export class RoomsService {
floor?: number;
capacity?: number;
roomType?: string;
rentalCategory?: string;
monthlyRate?: number;
}[],
) {
let imported = 0;
@@ -254,6 +266,8 @@ export class RoomsService {
floor: row.floor || parsed.floor || undefined,
capacity: row.capacity || parsed.capacity || 4,
roomType: row.roomType || parsed.roomType || undefined,
rentalCategory: row.rentalCategory || undefined,
monthlyRate: row.monthlyRate ?? undefined,
}),
);
imported++;

View File

@@ -5,9 +5,10 @@ import { SchedulesService } from './schedules.service';
import { SchedulesController } from './schedules.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { DepartmentsModule } from '../departments/departments.module';
@Module({
imports: [TypeOrmModule.forFeature([ClassSchedule]), OperationLogsModule, NotificationsModule],
imports: [TypeOrmModule.forFeature([ClassSchedule]), OperationLogsModule, NotificationsModule, DepartmentsModule],
controllers: [SchedulesController],
providers: [SchedulesService],
exports: [SchedulesService],

View File

@@ -2,6 +2,7 @@ import { Injectable, NotFoundException, ConflictException } from '@nestjs/common
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ClassSchedule } from '../entities';
import { CampusScope } from '../common/campus-scope';
import {
CreateScheduleDto,
UpdateScheduleDto,
@@ -14,10 +15,15 @@ export class SchedulesService {
constructor(
@InjectRepository(ClassSchedule)
private readonly scheduleRepo: Repository<ClassSchedule>,
private readonly scope: CampusScope,
) {}
async findAll(query: QueryScheduleDto) {
const qb = this.scheduleRepo.createQueryBuilder('cs');
const scopeIds = await this.scope.getScopeDepartmentIds();
if (scopeIds) {
qb.andWhere('cs.departmentId IN (:...scopeIds)', { scopeIds });
}
if (query.classroomId) qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId });
if (query.classId) qb.andWhere('cs.classId = :classId', { classId: query.classId });
@@ -102,7 +108,10 @@ export class SchedulesService {
async getWeeklyView(query: WeeklyViewQueryDto) {
const qb = this.scheduleRepo.createQueryBuilder('cs');
const scopeIds = await this.scope.getScopeDepartmentIds();
if (scopeIds) {
qb.andWhere('cs.departmentId IN (:...scopeIds)', { scopeIds });
}
if (query.classroomId) {
qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId });
}

View File

@@ -1,4 +1,4 @@
import { IsString, IsOptional, IsEnum } from 'class-validator';
import { IsString, IsOptional, IsEnum, IsNumber } from 'class-validator';
export class CreateStudentDto {
@IsString()
@@ -32,6 +32,10 @@ export class CreateStudentDto {
@IsString()
organization?: string;
@IsOptional()
@IsNumber()
tenantId?: number;
@IsOptional()
@IsString()
supervisor?: string;
@@ -70,6 +74,10 @@ export class UpdateStudentDto {
@IsString()
organization?: string;
@IsOptional()
@IsNumber()
tenantId?: number;
@IsOptional()
@IsString()
supervisor?: string;

View File

@@ -12,7 +12,11 @@ import {
Res,
UseInterceptors,
UploadedFile,
Inject,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Tenant } from '../entities/tenant.entity';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import { StudentsService } from './students.service';
@@ -26,9 +30,11 @@ import * as ExcelJS from 'exceljs';
@UseGuards(JwtAuthGuard)
@Controller('students')
export class StudentsController {
constructor(
private service: StudentsService,
private logService: OperationLogsService,
@InjectRepository(Tenant) private tenantRepo: Repository<Tenant>,
) {}
@Get()
@@ -55,7 +61,7 @@ export class StudentsController {
{ header: '民族', key: 'ethnicity', width: 10 },
{ header: '紧急联系人', key: 'emergencyContact', width: 15 },
{ header: '紧急联系人电话', key: 'emergencyPhone', width: 18 },
{ header: '所属机构', key: 'organization', width: 18 },
{ header: '所属机构', key: 'tenant', width: 18 },
{ header: '负责人/班主任', key: 'supervisor', width: 15 },
{ header: '状态', key: 'status', width: 10 },
];
@@ -76,7 +82,7 @@ export class StudentsController {
ethnicity: s.ethnicity || '',
emergencyContact: s.emergencyContact || '',
emergencyPhone: s.emergencyPhone || '',
organization: s.organization || '',
tenant: s.tenant?.name || '',
supervisor: s.supervisor || '',
status: statusMap[s.status] || s.status,
});
@@ -113,7 +119,7 @@ export class StudentsController {
{ header: '民族', key: 'ethnicity', width: 10 },
{ header: '紧急联系人', key: 'emergencyContact', width: 15 },
{ header: '紧急联系人电话', key: 'emergencyPhone', width: 18 },
{ header: '所属机构', key: 'organization', width: 18 },
{ header: '所属机构(租赁方名称)', key: 'tenant', width: 18 },
{ header: '负责人/班主任', key: 'supervisor', width: 15 },
];
ws.getRow(1).font = { bold: true };
@@ -126,7 +132,7 @@ export class StudentsController {
ethnicity: '汉族',
emergencyContact: '张父',
emergencyPhone: '13900000000',
organization: '',
tenant: 'XX教育公司',
supervisor: '',
});
res.setHeader(
@@ -251,8 +257,9 @@ export class StudentsController {
ethnicity?: string;
emergencyContact?: string;
emergencyPhone?: string;
organization?: string;
tenant?: string;
supervisor?: string;
tenantId?: number;
}[] = [];
ws.eachRow((row, idx) => {
if (idx === 1) return;
@@ -264,10 +271,19 @@ export class StudentsController {
ethnicity: String(row.getCell(5).value || '').trim() || undefined,
emergencyContact: String(row.getCell(6).value || '').trim() || undefined,
emergencyPhone: String(row.getCell(7).value || '').trim() || undefined,
organization: String(row.getCell(8).value || '').trim() || undefined,
tenant: String(row.getCell(8).value || '').trim() || undefined,
supervisor: String(row.getCell(9).value || '').trim() || undefined,
});
});
// Resolve tenant names to IDs
for (const row of rows) {
if (row.tenant) {
const tenant = await this.tenantRepo.findOne({ where: { name: row.tenant } });
if (tenant) {
row.tenantId = tenant.id;
}
}
}
const result = await this.service.batchImport(rows);
await this.logService.log({
userId: req.user?.id,

View File

@@ -1,13 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Student } from '../entities/student.entity';
import { DepartmentsModule } from '../departments/departments.module';
import { ClassStudent } from '../entities/class-student.entity';
import { AttendanceRecord } from '../entities/attendance-record.entity';
import { StudentsService } from './students.service';
import { StudentsController } from './students.controller';
@Module({
imports: [TypeOrmModule.forFeature([Student, ClassStudent, AttendanceRecord])],
imports: [TypeOrmModule.forFeature([Student, ClassStudent, AttendanceRecord]), DepartmentsModule],
controllers: [StudentsController],
providers: [StudentsService],
exports: [StudentsService],

View File

@@ -1,6 +1,7 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Like, Not, In } from 'typeorm';
import { CampusScope } from '../common/campus-scope';
import { Student } from '../entities/student.entity';
import { ClassStudent } from '../entities/class-student.entity';
import { AttendanceRecord } from '../entities/attendance-record.entity';
@@ -13,6 +14,7 @@ export class StudentsService {
@InjectRepository(Student) private repo: Repository<Student>,
@InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
@InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
private readonly scope: CampusScope,
) {}
async findAll(query?: { name?: string; status?: string; includeArchived?: boolean }) {
@@ -23,7 +25,8 @@ export class StudentsService {
} else if (!query?.includeArchived) {
where.status = Not('archived');
}
return this.repo.find({ where, order: { createdAt: 'DESC' } });
const filteredWhere = await this.scope.filter(where);
return this.repo.find({ where: filteredWhere, order: { createdAt: 'DESC' }, relations: ['tenant'] });
}
async findOne(id: number) {
@@ -101,6 +104,7 @@ export class StudentsService {
emergencyPhone?: string;
organization?: string;
supervisor?: string;
tenantId?: number;
}[],
) {
let imported = 0;
@@ -126,9 +130,9 @@ export class StudentsService {
emergencyPhone: row.emergencyPhone || undefined,
organization: row.organization || undefined,
supervisor: row.supervisor || undefined,
tenantId: row.tenantId || undefined,
}),
);
imported++;
}
return {
message: `成功导入 ${imported} 名学生,跳过 ${skipped} 条(重复或空行)`,