forked from wangziqi/gongxue-base
feat: integrate CampusScope.filter() into all business services (12 services + 12 modules)
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
35
apps/server/src/expense-types/dto/expense-type.dto.ts
Normal file
35
apps/server/src/expense-types/dto/expense-type.dto.ts
Normal 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;
|
||||
}
|
||||
84
apps/server/src/expense-types/expense-types.controller.ts
Normal file
84
apps/server/src/expense-types/expense-types.controller.ts
Normal 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: '已删除' };
|
||||
}
|
||||
}
|
||||
20
apps/server/src/expense-types/expense-types.module.ts
Normal file
20
apps/server/src/expense-types/expense-types.module.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
70
apps/server/src/expense-types/expense-types.service.ts
Normal file
70
apps/server/src/expense-types/expense-types.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 || '',
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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} 条(重复或空行)`,
|
||||
|
||||
Reference in New Issue
Block a user