refactor(server): remove Department/UserDepartment entities, CampusScope, and departmentId from all entities

- Delete department.entity.ts, user-department.entity.ts
- Remove Department/UserDepartment from entities/index.ts
- Remove departmentId column from 18 entities (AttendanceRecord, ArchiveAttachment, Bill, ClassSchedule, Classroom, ClassroomRental, Deposit, DepositInstallment, ExamScore, LearningRecord, Occupancy, PersonalExpense, ResultArchive, Room, RoomExpense, Student, StudentEnrollment, StudentProfile, StudentReport)
- Remove departments/ module entirely
- Delete campus-scope.ts, campus-scope.middleware.ts (request-utils.ts kept — it's just IP extraction)
- Simplify common.module.ts to empty module
- Remove CampusScopeMiddleware from app.module.ts
- Remove all CampusScope injections and filter calls across all services
- Remove departmentId from all DTOs and controllers
- Simplify dingtalk/wecom sync to only sync users (no dept table)
- Update seed module to remove department seeding
- Clean frontend compilation
This commit is contained in:
2026-07-09 17:51:32 +08:00
parent b0f7883f33
commit 6029d8e2fd
68 changed files with 220 additions and 1516 deletions

View File

@@ -1,4 +1,4 @@
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter';
@@ -32,8 +32,6 @@ import {
SyncLog,
SyncState,
Notification,
Department,
UserDepartment,
StudentProfile,
StudentEnrollment,
ExamScore,
@@ -63,7 +61,6 @@ import { SchedulesModule } from './schedules/schedules.module';
import { ClassroomRentalsModule } from './classroom-rentals/classroom-rentals.module';
import { SyncModule } from './sync/sync.module';
import { NotificationsModule } from './notifications/notifications.module';
import { DepartmentsModule } from './departments/departments.module';
import { CommonModule } from './common/common.module';
import { ArchiveModule } from './archive/archive.module';
import { ExpenseTypesModule } from './expense-types/expense-types.module';
@@ -71,7 +68,6 @@ import { ExpenseTypesModule } from './expense-types/expense-types.module';
import { IntegrationConfig, IntegrationConfigDetail } from './integration/entities/integration-config.entity';
import { IntegrationConfigModule } from './integration/config/config.module';
import { CampusScopeMiddleware } from './common/campus-scope.middleware';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
@@ -113,8 +109,6 @@ import { CampusScopeMiddleware } from './common/campus-scope.middleware';
AttendanceRecord,
DingAttendanceRaw,
Notification,
Department,
UserDepartment,
StudentProfile,
StudentEnrollment,
ExamScore,
@@ -167,7 +161,6 @@ import { CampusScopeMiddleware } from './common/campus-scope.middleware';
ClassroomRentalsModule,
SyncModule,
NotificationsModule,
DepartmentsModule,
CommonModule,
ArchiveModule,
IntegrationConfigModule,
@@ -179,8 +172,4 @@ import { CampusScopeMiddleware } from './common/campus-scope.middleware';
{ provide: APP_GUARD, useClass: PermissionGuard },
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(CampusScopeMiddleware).forRoutes('*');
}
}
export class AppModule {}

View File

@@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as fs from 'fs';
import * as path from 'path';
import { CampusScope } from '../common/campus-scope';
import { NotificationsService } from '../notifications/notifications.service';
import { Student } from '../entities/student.entity';
import { StudentProfile } from '../entities/student-profile.entity';
@@ -30,7 +30,6 @@ export class ArchiveService {
@InjectRepository(LearningRecord) private learningRecordRepo: Repository<LearningRecord>,
@InjectRepository(ResultArchive) private resultRepo: Repository<ResultArchive>,
@InjectRepository(ArchiveAttachment) private attachmentRepo: Repository<ArchiveAttachment>,
private readonly scope: CampusScope,
private readonly notificationsService: NotificationsService,
) {}
@@ -46,24 +45,12 @@ export class ArchiveService {
resultArchive,
attachments,
] = await Promise.all([
this.profileRepo.findOne({ where: await this.scope.filter({ studentId }) }),
this.enrollmentRepo.find({
where: await this.scope.filter({ studentId }),
order: { createdAt: 'DESC' },
}),
this.examScoreRepo.find({
where: await this.scope.filter({ studentId }),
order: { examDate: 'DESC' },
}),
this.learningRecordRepo.find({
where: await this.scope.filter({ studentId }),
order: { recordDate: 'DESC' },
}),
this.resultRepo.findOne({ where: await this.scope.filter({ studentId }) }),
this.attachmentRepo.find({
where: await this.scope.filter({ studentId }),
order: { createdAt: 'DESC' },
}),
this.profileRepo.findOne({ where: { studentId } }),
this.enrollmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
this.examScoreRepo.find({ where: { studentId }, order: { examDate: 'DESC' } }),
this.learningRecordRepo.find({ where: { studentId }, order: { recordDate: 'DESC' } }),
this.resultRepo.findOne({ where: { studentId } }),
this.attachmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
]);
return {

View File

@@ -43,10 +43,6 @@ export class AttendanceService {
throw new BadRequestException('records array must not be empty');
}
// Batch-load student departmentIds
const ids = [...new Set(dto.records.map((r) => r.studentId))];
const students = await this.studentRepo.find({ where: { id: In(ids) } });
const deptMap = new Map(students.map((s) => [s.id, s.departmentId]));
const entities = dto.records.map((r) => {
const entity = this.attendanceRepo.create({
@@ -58,7 +54,6 @@ export class AttendanceService {
remark: r.remark,
source: r.source || 'manual',
});
entity.departmentId = deptMap.get(r.studentId)!;
return entity;
});
@@ -128,7 +123,6 @@ export class AttendanceService {
status: 'pending',
source: 'schedule',
});
entity.departmentId = cs.student?.departmentId ?? undefined;
entities.push(entity);
existingKeys.add(key);
}

View File

@@ -9,7 +9,6 @@ import { PersonalExpense } from '../entities/personal-expense.entity';
import { Occupancy } from '../entities/occupancy.entity';
import { Room } from '../entities/room.entity';
import { Deposit } from '../entities/deposit.entity';
import { CampusScope } from '../common/campus-scope';
type MockRepository<T> = Partial<Record<keyof Repository<T>, jest.Mock>>;
@@ -70,7 +69,6 @@ describe('BillsService — generateBills', () => {
{ provide: getRepositoryToken(Room), useValue: roomRepo },
{ provide: getRepositoryToken(Deposit), useValue: depositRepo },
{ provide: DataSource, useValue: dataSource },
{ provide: CampusScope, useValue: { getScopeDepartmentIds: jest.fn().mockResolvedValue(null), filter: jest.fn((w: any) => w) } },
],
}).compile();

View File

@@ -9,7 +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 {
@@ -22,7 +22,6 @@ export class BillsService {
@InjectRepository(Room) private roomRepo: Repository<Room>,
@InjectRepository(Deposit) private depositRepo: Repository<Deposit>,
private dataSource: DataSource,
private readonly scope: CampusScope,
) {}
/**
@@ -181,15 +180,6 @@ export class BillsService {
// 合并所有涉及的学生
const allStudentIds = new Set([...studentBillData.keys(), ...personalMap.keys()]);
// 生成账单
// Batch-load student departmentIds
const studentIdsArr = [...allStudentIds];
const studentDeptMap = new Map<number, number>();
if (studentIdsArr.length > 0) {
const studentsData: { id: number; department_id: number | null }[] = await this.dataSource.query(
`SELECT id, department_id FROM students WHERE id IN (${studentIdsArr.join(',')})`
);
for (const s of studentsData) if (s.department_id != null) studentDeptMap.set(s.id, s.department_id);
}
const bills: Bill[] = [];
for (const studentId of allStudentIds) {
const shared = studentBillData.get(studentId)?.shared || 0;
@@ -204,7 +194,6 @@ export class BillsService {
personalAmount: personal,
totalAmount: total,
status: 'draft',
departmentId: studentDeptMap.get(studentId),
});
const savedBill = await this.billRepo.save(bill);
@@ -229,12 +218,10 @@ export class BillsService {
status?: string;
expenseType?: 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,14 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CommonModule } from '../common/common.module';
import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, Department, Student, StudentDingMapping } from '../entities';
import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, Student, StudentDingMapping } from '../entities';
import { ClassesService } from './classes.service';
import { ClassesController } from './classes.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, Department, Student, StudentDingMapping]), OperationLogsModule, NotificationsModule, CommonModule],
imports: [TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, Student, StudentDingMapping]), OperationLogsModule, NotificationsModule, CommonModule],
controllers: [ClassesController],
providers: [ClassesService],
exports: [ClassesService],

View File

@@ -147,7 +147,6 @@ export class ClassesService {
this.studentRepo.create({
name: `dd_${dingUserId}`,
status: 'active',
departmentId: undefined,
})
);
const savedStudents = await this.studentRepo.save(newStudents);

View File

@@ -7,7 +7,6 @@ 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 { CampusScope } from '../common/campus-scope';
import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto';
function mockQueryBuilder<T>(results: T[] = []) {
@@ -33,7 +32,6 @@ describe('ClassroomRentalsService — findConflicts', () => {
{ provide: getRepositoryToken(Classroom), useValue: {} },
{ provide: getRepositoryToken(Tenant), useValue: {} },
{ provide: getRepositoryToken(ClassSchedule), useValue: { createQueryBuilder: jest.fn() } },
{ provide: CampusScope, useValue: { getScopeDepartmentIds: jest.fn().mockResolvedValue(null) } },
],
}).compile();
@@ -134,7 +132,6 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
{ provide: getRepositoryToken(Classroom), useValue: classroomRepo },
{ provide: getRepositoryToken(Tenant), useValue: tenantRepo },
{ provide: getRepositoryToken(ClassSchedule), useValue: scheduleRepo },
{ provide: CampusScope, useValue: { getScopeDepartmentIds: jest.fn().mockResolvedValue(null) } },
],
}).compile();

View File

@@ -11,7 +11,7 @@ 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';
@@ -37,7 +37,6 @@ export class ClassroomRentalsService {
@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 {
@@ -57,13 +56,11 @@ 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) {
@@ -142,9 +139,7 @@ export class ClassroomRentalsService {
});
}
const rental = this.repo.create({ ...dto, createdBy: userId, status: 'active' });
rental.departmentId = classroom.departmentId;
const saved = await this.repo.save(rental);
await this.syncScheduleFromRental(saved, tenant.name);
return saved;
}
@@ -221,7 +216,6 @@ export class ClassroomRentalsService {
rentalId: rental.id,
status: 'active',
notes: rental.notes,
departmentId: rental.departmentId,
};
if (schedule) {
await this.scheduleRepo.update(schedule.id, data);

View File

@@ -211,8 +211,7 @@ export class ClassroomsController {
supervisor: String(row.getCell(7).value || '') || undefined,
});
});
const departmentId = req.headers?.['x-campus-id'] ? parseInt(String(req.headers['x-campus-id']), 10) || undefined : undefined;
const result = await this.service.batchImport(rows, departmentId);
const result = await this.service.batchImport(rows);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,

View File

@@ -4,7 +4,6 @@ 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()
@@ -14,7 +13,6 @@ export class ClassroomsService {
@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 }) {
@@ -22,7 +20,7 @@ export class ClassroomsService {
if (query?.building) where.building = query.building;
if (query?.roomType) where.roomType = query.roomType;
if (!query?.includeArchived) where.status = Not('archived');
const list = await this.repo.find({ where: await this.scope.filter(where), order: { building: 'ASC', name: 'ASC' } });
const list = await this.repo.find({ where, order: { building: 'ASC', name: 'ASC' } });
const usageMap = await this.getCurrentUsageForClassrooms(list.map((c) => c.id));
return list.map((c) => ({ ...c, currentUsage: usageMap.get(c.id) ?? null }));
}
@@ -37,9 +35,7 @@ export class ClassroomsService {
async create(dto: CreateClassroomDto) {
const exists = await this.repo.findOne({ where: { name: dto.name } });
if (exists) throw new BadRequestException(`教室 ${dto.name} 已存在`);
const entity = this.repo.create(dto);
if (dto.departmentId) entity.departmentId = dto.departmentId;
return this.repo.save(entity);
return this.repo.save(this.repo.create(dto));
}
async update(id: number, dto: UpdateClassroomDto) {
@@ -136,7 +132,6 @@ export class ClassroomsService {
roomType?: string;
courseType?: string;
}[],
departmentId?: number,
) {
let imported = 0;
let skipped = 0;
@@ -145,7 +140,7 @@ export class ClassroomsService {
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, departmentId: departmentId ?? undefined }));
await this.repo.save(this.repo.create({ ...row, capacity: row.capacity || 30 }));
imported++;
}
return { message: `成功导入 ${imported} 间教室,跳过 ${skipped}`, imported, skipped, errors: errors.length > 0 ? errors : undefined };

View File

@@ -33,9 +33,6 @@ export class CreateClassroomDto {
@IsString()
notes?: string;
@IsOptional()
@IsInt()
departmentId?: number;
}
export class UpdateClassroomDto {

View File

@@ -1,13 +0,0 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { CampusScope, CampusRequest } from './campus-scope';
@Injectable()
export class CampusScopeMiddleware implements NestMiddleware {
constructor(private readonly scope: CampusScope) {}
use(req: Request, _res: Response, next: NextFunction): void {
(req as CampusRequest).campusScope = this.scope;
next();
}
}

View File

@@ -1,77 +0,0 @@
import { Injectable, Scope, Inject } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { In } from 'typeorm';
import { Request } from 'express';
import { DepartmentsService } from '../departments/departments.service';
export interface CampusRequest extends Request {
user?: {
id: number;
isSuperAdmin?: boolean;
};
campusScope?: CampusScope;
}
@Injectable({ scope: Scope.REQUEST })
export class CampusScope {
constructor(
@Inject(REQUEST) private req: CampusRequest,
private departmentsService: DepartmentsService,
) {}
get userId(): number | undefined {
return this.req.user?.id;
}
get isSuperAdmin(): boolean {
return this.req.user?.isSuperAdmin ?? false;
}
get currentDepartmentId(): number | null {
const raw = this.req.headers?.['x-campus-id'];
if (raw === undefined) return null;
const str = Array.isArray(raw) ? raw[0] : raw;
if (!str) return null;
const id = parseInt(str, 10);
return Number.isNaN(id) ? null : id;
}
/** Appends departmentId filter. No campus selected = no filtering for super admin; empty result for others. */
async filter<T extends Record<string, unknown>>(where: T): Promise<T> {
if (this.isSuperAdmin && !this.currentDepartmentId) {
return where;
}
const ids = await this.getEffectiveScopeIds();
if (ids.length === 0) {
// Non-super-admin with no scoping → match nothing, never leak unfiltered data
if (!this.isSuperAdmin) {
return { ...where, departmentId: In([]) };
}
return where;
}
return { ...where, departmentId: In(ids) };
}
/** Returns department IDs for QueryBuilder .andWhere(). null = no filtering. */
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[]> {
if (this.currentDepartmentId) {
return this.departmentsService.getDescendantIds(this.currentDepartmentId);
}
if (!this.userId) return [];
const userDeptIds = await this.departmentsService.getUserDepartments(this.userId);
const allIds = await Promise.all(
userDeptIds.map((id) => this.departmentsService.getDescendantIds(id)),
);
return [...new Set(allIds.flat())];
}
}

View File

@@ -1,11 +1,4 @@
import { Module } from '@nestjs/common';
import { CampusScope } from './campus-scope';
import { CampusScopeMiddleware } from './campus-scope.middleware';
import { DepartmentsModule } from '../departments/departments.module';
@Module({
imports: [DepartmentsModule],
providers: [CampusScope, CampusScopeMiddleware],
exports: [CampusScope, CampusScopeMiddleware, DepartmentsModule],
})
@Module({})
export class CommonModule {}

View File

@@ -13,7 +13,7 @@ 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 {
@@ -31,23 +31,20 @@ 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: 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 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 capQb = this.roomRepo
.createQueryBuilder('r')
.select('SUM(r.capacity)', 'total')
.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;
@@ -58,11 +55,10 @@ export class DashboardService {
.addSelect('COUNT(*)', 'count')
.addSelect('SUM(b.totalAmount)', 'total')
.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({ where: await this.scope.filter({}) });
const classroomCount = await this.classroomRepo.count({ where: {} });
const occQb = this.scheduleRepo
.createQueryBuilder('s')
@@ -70,7 +66,6 @@ export class DashboardService {
.where('s.status = :active', { active: 'active' })
.andWhere('s.startDate <= :today', { today: todayStr })
.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
@@ -83,7 +78,6 @@ export class DashboardService {
.addSelect('COUNT(*)', 'count')
.where('a.attendanceDate = :today', { today: todayStr })
.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
@@ -99,7 +93,6 @@ export class DashboardService {
.where('b.status = :paid', { paid: 'paid' })
.andWhere('b.periodStart >= :start', { start: `${currentMonth}-01` })
.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');
@@ -107,9 +100,8 @@ export class DashboardService {
const incomeTrend = await this.getIncomeTrend(currentMonth);
// --- New stats ---
const classCount = await this.classRepo.count({ where: await this.scope.filter({}) });
const classCount = await this.classRepo.count({ where: {} });
// classTeacherRepo does not have departmentId — skip scope filtering
const teacherResult = await this.classTeacherRepo
.createQueryBuilder('ct')
.select('COUNT(DISTINCT ct.userId)', 'cnt')
@@ -121,11 +113,10 @@ export class DashboardService {
.select('SUM(d.amount)', 'total')
.where('d.status = :paid', { paid: 'paid' })
.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: await this.scope.filter({ endDate: MoreThanOrEqual(todayStr) }) });
const activeRentals = await this.rentalRepo.count({ where: { endDate: MoreThanOrEqual(todayStr) } });
const occByBldQb = this.occRepo
.createQueryBuilder('o')
@@ -133,7 +124,6 @@ export class DashboardService {
.select('r.building', 'building')
.addSelect('COUNT(*)', 'count')
.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) => {
@@ -147,7 +137,6 @@ export class DashboardService {
.addSelect('SUM(e.amount)', 'total')
.where('e.periodStart >= :start', { start: `${currentMonth}-01` })
.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 {
@@ -238,7 +227,6 @@ 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')
@@ -247,7 +235,6 @@ 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 });
@@ -283,13 +270,11 @@ export class DashboardService {
}
// 费用统计
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();
@@ -297,7 +282,6 @@ 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')
@@ -307,7 +291,6 @@ 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();
@@ -315,7 +298,6 @@ export class DashboardService {
// 班级考勤排行
async getClassAttendanceRanking() {
const scopeIds = await this.scope.getScopeDepartmentIds();
const qb = this.attendanceRepo
.createQueryBuilder('a')
.leftJoin('a.class', 'class')
@@ -324,7 +306,6 @@ export class DashboardService {
.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 }>();
@@ -345,9 +326,8 @@ export class DashboardService {
}
async getClassroomOccupancy() {
const scopeIds = await this.scope.getScopeDepartmentIds();
const classrooms = await this.classroomRepo.find({
where: await this.scope.filter({ status: Not('archived') }),
where: { status: Not('archived') },
order: { building: 'ASC', name: 'ASC' },
});
const today = new Date().toISOString().slice(0, 10);
@@ -359,7 +339,6 @@ export class DashboardService {
.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')
@@ -368,7 +347,6 @@ export class DashboardService {
.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> = {};
@@ -385,9 +363,8 @@ export class DashboardService {
}
async getClassroomUtilizationStats() {
const scopeIds = await this.scope.getScopeDepartmentIds();
const totalClassrooms = await this.classroomRepo.count({
where: await this.scope.filter({ status: Not('archived') }),
where: { status: Not('archived') },
});
const today = new Date().toISOString().slice(0, 10);
@@ -399,7 +376,6 @@ export class DashboardService {
.where('s.status = :active', { active: 'active' })
.andWhere('s.scheduleType = :type', { type: 'INTERNAL' })
.andWhere('s.startDate <= :today AND s.endDate >= :today', { today });
if (scopeIds) schedQb.andWhere('s.departmentId IN (:...scopeIds)', { scopeIds });
const schedResult = await schedQb.getRawOne();
// Count classrooms with active rentals today
@@ -408,7 +384,6 @@ export class DashboardService {
.select('COUNT(DISTINCT r.classroomId)', 'cnt')
.where('r.status != :cancelled', { cancelled: 'cancelled' })
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today });
if (scopeIds) rentalQb.andWhere('r.departmentId IN (:...scopeIds)', { scopeIds });
const rentalResult = await rentalQb.getRawOne();
// Combine: use Set merge of both
@@ -419,7 +394,6 @@ export class DashboardService {
.andWhere('s.scheduleType = :type', { type: 'INTERNAL' })
.andWhere('s.startDate <= :today AND s.endDate >= :today', { today })
.groupBy('s.classroomId');
if (scopeIds) combinedQb.andWhere('s.departmentId IN (:...scopeIds)', { scopeIds });
const schedIds = await combinedQb.getRawMany();
const combinedRentalQb = this.rentalRepo
@@ -428,7 +402,6 @@ export class DashboardService {
.where('r.status != :cancelled', { cancelled: 'cancelled' })
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today })
.groupBy('r.classroomId');
if (scopeIds) combinedRentalQb.andWhere('r.departmentId IN (:...scopeIds)', { scopeIds });
const rentalIds = await combinedRentalQb.getRawMany();
const allInUseIds = new Set([

View File

@@ -1,95 +0,0 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
ParseIntPipe,
UseGuards,
} from '@nestjs/common';
import { DepartmentsService } from './departments.service';
import {
CreateDepartmentDto,
UpdateDepartmentDto,
AssignUserDto,
} from './dto/department.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequirePermission } from '../auth/decorators/permission.decorator';
@UseGuards(JwtAuthGuard)
@Controller('departments')
export class DepartmentsController {
constructor(private readonly departmentsService: DepartmentsService) {}
@Get()
@RequirePermission('department:view')
findAll() {
return this.departmentsService.findAll();
}
@Get('tree')
@RequirePermission('department:view')
findTree() {
return this.departmentsService.findTree();
}
/** 获取钉钉同步的部门树,供"从部门创建班级"使用 */
@Get('synced')
@RequirePermission('department:view')
findSyncedTree() {
return this.departmentsService.findSyncedTree();
}
@Get(':id')
@RequirePermission('department:view')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.departmentsService.findOne(id);
}
@Post()
@RequirePermission('department:edit')
create(@Body() dto: CreateDepartmentDto) {
return this.departmentsService.create(dto);
}
@Put(':id')
@RequirePermission('department:edit')
update(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateDepartmentDto,
) {
return this.departmentsService.update(id, dto);
}
@Delete(':id')
@RequirePermission('department:delete')
remove(@Param('id', ParseIntPipe) id: number) {
return this.departmentsService.remove(id);
}
@Get(':id/users')
@RequirePermission('department:view')
getUsers(@Param('id', ParseIntPipe) id: number) {
return this.departmentsService.getUsers(id);
}
@Post(':id/users')
@RequirePermission('department:edit')
assignUser(
@Param('id', ParseIntPipe) id: number,
@Body() dto: AssignUserDto,
) {
return this.departmentsService.assignUser(id, dto);
}
@Delete(':id/users/:userId')
@RequirePermission('department:delete')
removeUser(
@Param('id', ParseIntPipe) id: number,
@Param('userId', ParseIntPipe) userId: number,
) {
return this.departmentsService.removeUser(id, userId);
}
}

View File

@@ -1,14 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Department } from '../entities/department.entity';
import { UserDepartment } from '../entities/user-department.entity';
import { DepartmentsService } from './departments.service';
import { DepartmentsController } from './departments.controller';
@Module({
imports: [TypeOrmModule.forFeature([Department, UserDepartment])],
controllers: [DepartmentsController],
providers: [DepartmentsService],
exports: [DepartmentsService],
})
export class DepartmentsModule {}

View File

@@ -1,191 +0,0 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Department } from '../entities/department.entity';
import { DepartmentType } from '../entities/department.entity';
import { UserDepartment } from '../entities/user-department.entity';
import { CreateDepartmentDto, UpdateDepartmentDto, AssignUserDto } from './dto/department.dto';
@Injectable()
export class DepartmentsService {
constructor(
@InjectRepository(Department)
private deptRepo: Repository<Department>,
@InjectRepository(UserDepartment)
private userDeptRepo: Repository<UserDepartment>,
) {}
async findAll(): Promise<Department[]> {
return this.deptRepo.find({
where: { status: 'active' },
order: { sortOrder: 'ASC', name: 'ASC' },
});
}
async findTree(): Promise<Department[]> {
// Load ALL departments flat (no relations — avoids N+1 and only loads 1 level),
// then build the full tree in memory.
const all = await this.deptRepo.find({
where: { status: 'active' },
order: { sortOrder: 'ASC', name: 'ASC' },
});
const byParent = new Map<number | null, Department[]>();
for (const dept of all) {
const key = dept.parentId ?? null;
const list = byParent.get(key);
if (list) {
list.push(dept);
} else {
byParent.set(key, [dept]);
}
}
const attachChildren = (dept: Department): void => {
const children = byParent.get(dept.id) ?? [];
dept.children = children;
for (const child of children) attachChildren(child);
};
const roots = byParent.get(null) ?? [];
for (const root of roots) attachChildren(root);
return roots;
}
/** 获取钉钉同步的部门树(只返回 source='dingtalk' 的部门) */
async findSyncedTree(): Promise<Department[]> {
const all = await this.deptRepo.find({
where: { status: 'active', source: 'dingtalk' },
order: { sortOrder: 'ASC', name: 'ASC' },
});
const byParent = new Map<number | null, Department[]>();
for (const dept of all) {
const key = dept.parentId ?? null;
const list = byParent.get(key);
if (list) {
list.push(dept);
} else {
byParent.set(key, [dept]);
}
}
const attachChildren = (dept: Department): void => {
const children = byParent.get(dept.id) ?? [];
dept.children = children;
for (const child of children) attachChildren(child);
};
const roots = byParent.get(null) ?? [];
for (const root of roots) attachChildren(root);
return roots;
}
async findOne(id: number): Promise<Department> {
const dept = await this.deptRepo.findOne({ where: { id } });
if (!dept) throw new NotFoundException('部门不存在');
return dept;
}
async create(dto: CreateDepartmentDto): Promise<Department> {
if (dto.type === DepartmentType.CAMPUS && dto.parentId) {
throw new ConflictException('校区类型的部门必须是根部门,不能设置上级');
}
const dept = this.deptRepo.create(dto);
return this.deptRepo.save(dept);
}
async update(id: number, dto: UpdateDepartmentDto): Promise<Department> {
const dept = await this.findOne(id);
const effectiveType = dto.type ?? dept.type;
const effectiveParentId = dto.parentId !== undefined ? dto.parentId : dept.parentId;
if (effectiveType === DepartmentType.CAMPUS && effectiveParentId) {
throw new ConflictException('校区类型的部门必须是根部门,不能设置上级');
}
// Prevent parent cycles: parentId must not be the dept itself or one of its descendants
if (dto.parentId !== undefined && dto.parentId !== null) {
const newParentId = dto.parentId;
if (newParentId === id) {
throw new ConflictException('不能将部门的上级设为自身');
}
const descendantIds = await this.getDescendantIds(id);
if (descendantIds.includes(newParentId)) {
throw new ConflictException('不能将部门的上级设为其子部门,会形成循环');
}
}
Object.assign(dept, dto);
return this.deptRepo.save(dept);
}
async remove(id: number): Promise<void> {
const children = await this.deptRepo.count({ where: { parentId: id } });
if (children > 0) throw new ConflictException('该部门下存在子部门,无法删除');
const users = await this.userDeptRepo.count({ where: { departmentId: id } });
if (users > 0) throw new ConflictException('该部门下有用户关联,无法删除');
await this.deptRepo.update(id, { status: 'archived' });
}
/** 获取部门的所有子部门 ID递归含自身 */
async getDescendantIds(departmentId: number): Promise<number[]> {
const all = await this.deptRepo.find({ where: { status: 'active' }, select: ['id', 'parentId'] });
const byParent = new Map<number | null, number[]>();
for (const d of all) {
const key = d.parentId ?? null;
byParent.set(key, [...(byParent.get(key) ?? []), d.id]);
}
const ids: number[] = [departmentId];
const collect = (parentId: number) => {
const children = byParent.get(parentId) ?? [];
for (const childId of children) {
ids.push(childId);
collect(childId);
}
};
collect(departmentId);
return ids;
}
/** 获取用户可访问的部门 ID 列表 */
async getUserDepartments(userId: number): Promise<number[]> {
const records = await this.userDeptRepo.find({
where: { userId },
});
return records.map((r) => r.departmentId);
}
/** 获取用户默认校区 ID */
async getUserDefaultDepartmentId(userId: number): Promise<number | null> {
const record = await this.userDeptRepo.findOne({
where: { userId, isDefault: true },
});
return record?.departmentId ?? null;
}
/** 获取部门下的用户 */
async getUsers(departmentId: number): Promise<UserDepartment[]> {
return this.userDeptRepo.find({
where: { departmentId },
relations: ['user'],
});
}
/** 为用户分配部门 */
async assignUser(departmentId: number, dto: AssignUserDto): Promise<UserDepartment> {
const record = this.userDeptRepo.create({
userId: dto.userId,
departmentId,
isDefault: dto.isDefault ?? false,
});
return this.userDeptRepo.save(record);
}
/** 移除用户-部门关联 */
async removeUser(departmentId: number, userId: number): Promise<void> {
await this.userDeptRepo.delete({ departmentId, userId });
}
}

View File

@@ -1,37 +0,0 @@
import { IsString, IsNotEmpty, IsOptional, IsInt, IsBoolean } from 'class-validator';
export class CreateDepartmentDto {
@IsString() @IsNotEmpty()
name: string;
@IsOptional() @IsInt()
parentId?: number;
@IsOptional() @IsString()
type?: string;
@IsOptional() @IsInt()
sortOrder?: number;
}
export class UpdateDepartmentDto {
@IsOptional() @IsString()
name?: string;
@IsOptional() @IsInt()
parentId?: number;
@IsOptional() @IsString()
type?: string;
@IsOptional() @IsInt()
sortOrder?: number;
}
export class AssignUserDto {
@IsInt()
userId: number;
@IsOptional() @IsBoolean()
isDefault?: boolean;
}

View File

@@ -1,23 +0,0 @@
import { DataSource } from 'typeorm';
export async function seedDefaultCampus(dataSource: DataSource) {
const deptRepo = dataSource.getRepository('departments');
const userDeptRepo = dataSource.getRepository('user_departments');
const existing = await deptRepo.count();
if (existing > 0) { console.log('Departments exist, skip seed'); return; }
const campus = await deptRepo.save({ name: '主校区', type: 'campus', sortOrder: 0 });
const tables = ['students','rooms','classrooms','class_schedule','attendance_records','room_expenses','personal_expenses','occupancies','bills','deposits','deposit_installments','classroom_rentals'];
for (const table of tables) {
await dataSource.query(`UPDATE ${table} SET department_id = ? WHERE department_id IS NULL`, [campus.id]);
}
const users = await dataSource.query('SELECT id FROM users');
for (const user of users) {
await userDeptRepo.save({ userId: user.id, departmentId: campus.id, isDefault: true });
}
console.log('Seed: default campus created, data backfilled');
}

View File

@@ -4,7 +4,7 @@ import { Repository } from 'typeorm';
import { Deposit } from '../entities/deposit.entity';
import { Student } from '../entities/student.entity';
import { DepositInstallment } from '../entities/deposit-installment.entity';
import { CampusScope } from '../common/campus-scope';
import { CreateDepositDto, RefundDepositDto, CreateDepositWithInstallmentsDto } from './dto/deposit.dto';
@Injectable()
@@ -16,17 +16,14 @@ export class DepositsService {
private installmentRepo: Repository<DepositInstallment>,
@InjectRepository(Student)
private studentRepo: Repository<Student>,
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();
@@ -49,7 +46,6 @@ export class DepositsService {
status: 'paid',
recordedBy: userId,
});
deposit.departmentId = student.departmentId;
if (dto instanceof CreateDepositWithInstallmentsDto && dto.installments?.length) {
deposit.installments = dto.installments.map((i) => {
@@ -58,7 +54,6 @@ export class DepositsService {
dueDate: i.dueDate,
status: 'pending',
});
inst.departmentId = student.departmentId;
return inst;
});
}
@@ -174,11 +169,10 @@ export class DepositsService {
return this.repo.save(deposit);
}
async findPendingRefunds() {
const baseWhere = await this.scope.filter({});
return this.repo.find({
where: [
{ ...baseWhere, refundStatus: 'pending' },
{ ...baseWhere, refundStatus: 'head_teacher_approved' },
{ refundStatus: 'pending' },
{ refundStatus: 'head_teacher_approved' },
],
relations: ['student', 'installments'],
order: { refundRequestedAt: 'DESC' },
@@ -193,13 +187,11 @@ export class DepositsService {
}
async getStats() {
const scopeIds = await this.scope.getScopeDepartmentIds();
const qb = this.repo
.createQueryBuilder('d')
.select('d.status', 'status')
.addSelect('COUNT(*)', 'count')
.addSelect('SUM(d.amount)', 'totalAmount');
if (scopeIds) qb.andWhere('d.departmentId IN (:...scopeIds)', { scopeIds });
qb.groupBy('d.status');
return qb.getRawMany();
}

View File

@@ -8,7 +8,6 @@ import {
JoinColumn,
} from 'typeorm';
import { Student } from './student.entity';
import { Department } from './department.entity';
@Entity('archive_attachments')
export class ArchiveAttachment {
@@ -37,12 +36,6 @@ export class ArchiveAttachment {
@Column({ name: 'mime_type', length: 100, nullable: true })
mimeType: string;
@Column({ name: 'department_id', type: 'integer', nullable: true })
departmentId: number;
@ManyToOne(() => Department, { nullable: true })
@JoinColumn({ name: 'department_id' })
department: Department;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;

View File

@@ -10,7 +10,6 @@ import {
} from 'typeorm';
import { Student } from './student.entity';
import { Class } from './class.entity';
import { Department } from './department.entity';
@Entity('attendance_records')
@Index(['classId', 'attendanceDate'])
@@ -54,10 +53,4 @@ export class AttendanceRecord {
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
@Column({ name: 'department_id', type: 'integer', nullable: true })
departmentId: number;
@ManyToOne(() => Department, { nullable: true })
@JoinColumn({ name: 'department_id' })
department: Department;
}

View File

@@ -9,7 +9,6 @@ import {
} from 'typeorm';
import { Student } from './student.entity';
import { BillItem } from './bill-item.entity';
import { Department } from './department.entity';
@Entity('bills')
export class Bill {
@@ -47,10 +46,4 @@ export class Bill {
@OneToMany(() => BillItem, (bi) => bi.bill)
items: BillItem[];
@Column({ name: 'department_id', type: 'integer', nullable: true })
departmentId: number;
@ManyToOne(() => Department, { nullable: true })
@JoinColumn({ name: 'department_id' })
department: Department;
}

View File

@@ -8,7 +8,6 @@ import {
JoinColumn,
Check,
} from 'typeorm';
import { Department } from './department.entity';
export enum ScheduleType {
INTERNAL = 'INTERNAL',
@@ -81,10 +80,4 @@ export class ClassSchedule {
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
@Column({ name: 'department_id', type: 'integer', nullable: true })
departmentId: number;
@ManyToOne(() => Department, { nullable: true })
@JoinColumn({ name: 'department_id' })
department: Department;
}

View File

@@ -10,7 +10,6 @@ import {
} from 'typeorm';
import { Classroom } from './classroom.entity';
import { Tenant } from './tenant.entity';
import { Department } from './department.entity';
@Entity('classroom_rentals')
@Index(['classroomId', 'startDate', 'endDate'])
@@ -66,10 +65,4 @@ export class ClassroomRental {
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
@Column({ name: 'department_id', type: 'integer', nullable: true })
departmentId: number;
@ManyToOne(() => Department, { nullable: true })
@JoinColumn({ name: 'department_id' })
department: Department;
}

View File

@@ -1,5 +1,4 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
import { Department } from './department.entity';
@Entity('classrooms')
export class Classroom {
@@ -36,10 +35,4 @@ export class Classroom {
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@Column({ name: 'department_id', type: 'integer', nullable: true })
departmentId: number;
@ManyToOne(() => Department, { nullable: true })
@JoinColumn({ name: 'department_id' })
department: Department;
}

View File

@@ -1,58 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
} from 'typeorm';
export enum DepartmentType {
CAMPUS = 'campus',
DEPARTMENT = 'department',
}
@Entity('departments')
export class Department {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 100 })
name: string;
@Column({ name: 'parent_id', type: 'integer', nullable: true })
parentId: number;
@ManyToOne(() => Department, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'parent_id' })
parent: Department;
@OneToMany(() => Department, (d) => d.parent)
children: Department[];
@Column({ length: 20, default: DepartmentType.DEPARTMENT })
type: string;
@Column({ name: 'sort_order', type: 'integer', default: 0 })
sortOrder: number;
@Column({ length: 20, default: 'active' })
status: string;
@Column({ length: 20, nullable: true })
source: string;
@Column({ name: 'source_id', length: 50, nullable: true })
sourceId: string;
@Column({ name: 'parent_source_id', length: 50, nullable: true })
parentSourceId: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@@ -7,7 +7,6 @@ import {
JoinColumn,
} from 'typeorm';
import { Deposit } from './deposit.entity';
import { Department } from './department.entity';
@Entity('deposit_installments')
export class DepositInstallment {
@@ -36,10 +35,4 @@ export class DepositInstallment {
@JoinColumn({ name: 'deposit_id' })
deposit: Deposit;
@Column({ name: 'department_id', type: 'integer', nullable: true })
departmentId: number;
@ManyToOne(() => Department, { nullable: true })
@JoinColumn({ name: 'department_id' })
department: Department;
}

View File

@@ -9,7 +9,6 @@ import {
} from 'typeorm';
import { Student } from './student.entity';
import { DepositInstallment } from './deposit-installment.entity';
import { Department } from './department.entity';
@Entity('deposits')
export class Deposit {
@@ -72,10 +71,4 @@ export class Deposit {
@JoinColumn({ name: 'student_id' })
student: Student;
@Column({ name: 'department_id', type: 'integer', nullable: true })
departmentId: number;
@ManyToOne(() => Department, { nullable: true })
@JoinColumn({ name: 'department_id' })
department: Department;
}

View File

@@ -9,7 +9,6 @@ import {
} from 'typeorm';
import { Student } from './student.entity';
import { StudentEnrollment } from './student-enrollment.entity';
import { Department } from './department.entity';
@Entity('exam_scores')
export class ExamScore {
@@ -51,12 +50,6 @@ export class ExamScore {
@Column({ name: 'exam_date', type: 'date', nullable: true })
examDate: string;
@Column({ name: 'department_id', type: 'integer', nullable: true })
departmentId: number;
@ManyToOne(() => Department, { nullable: true })
@JoinColumn({ name: 'department_id' })
department: Department;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;

View File

@@ -26,8 +26,6 @@ export { SyncLog } from './sync-log.entity';
export { SyncState } from './sync-state.entity';
export { ExpenseType } from './expense-type.entity';
export { Notification, NotificationType } from './notification.entity';
export { Department, DepartmentType } from './department.entity';
export { UserDepartment } from './user-department.entity';
export { StudentProfile } from './student-profile.entity';
export { StudentEnrollment } from './student-enrollment.entity';
export { ExamScore } from './exam-score.entity';

View File

@@ -8,7 +8,6 @@ import {
JoinColumn,
} from 'typeorm';
import { Student } from './student.entity';
import { Department } from './department.entity';
@Entity('learning_records')
export class LearningRecord {
@@ -37,12 +36,6 @@ export class LearningRecord {
@Column({ name: 'next_step', type: 'text', nullable: true })
nextStep: string;
@Column({ name: 'department_id', type: 'integer', nullable: true })
departmentId: number;
@ManyToOne(() => Department, { nullable: true })
@JoinColumn({ name: 'department_id' })
department: Department;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;

View File

@@ -11,7 +11,6 @@ import { Room } from './room.entity';
import { Tenant } from './tenant.entity';
import { Bed } from './bed.entity';
import { Locker } from './locker.entity';
import { Department } from './department.entity';
@Entity('occupancies')
export class Occupancy {
@@ -77,10 +76,4 @@ export class Occupancy {
@JoinColumn({ name: 'room_id' })
room: Room;
@Column({ name: 'department_id', type: 'integer', nullable: true })
departmentId: number;
@ManyToOne(() => Department, { nullable: true })
@JoinColumn({ name: 'department_id' })
department: Department;
}

View File

@@ -7,7 +7,6 @@ import {
JoinColumn,
} from 'typeorm';
import { Student } from './student.entity';
import { Department } from './department.entity';
@Entity('personal_expenses')
export class PersonalExpense {
@@ -42,10 +41,4 @@ export class PersonalExpense {
@JoinColumn({ name: 'student_id' })
student: Student;
@Column({ name: 'department_id', type: 'integer', nullable: true })
departmentId: number;
@ManyToOne(() => Department, { nullable: true })
@JoinColumn({ name: 'department_id' })
department: Department;
}

View File

@@ -8,7 +8,6 @@ import {
JoinColumn,
} from 'typeorm';
import { Student } from './student.entity';
import { Department } from './department.entity';
@Entity('result_archives')
export class ResultArchive {
@@ -37,12 +36,6 @@ export class ResultArchive {
@Column({ name: 'admitted_major', length: 100, nullable: true })
admittedMajor: string;
@Column({ name: 'department_id', type: 'integer', nullable: true })
departmentId: number;
@ManyToOne(() => Department, { nullable: true })
@JoinColumn({ name: 'department_id' })
department: Department;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;

View File

@@ -7,7 +7,6 @@ import {
JoinColumn,
} from 'typeorm';
import { Room } from './room.entity';
import { Department } from './department.entity';
@Entity('room_expenses')
export class RoomExpense {
@@ -42,10 +41,4 @@ export class RoomExpense {
@JoinColumn({ name: 'room_id' })
room: Room;
@Column({ name: 'department_id', type: 'integer', nullable: true })
departmentId: number;
@ManyToOne(() => Department, { nullable: true })
@JoinColumn({ name: 'department_id' })
department: Department;
}

View File

@@ -1,7 +1,6 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, OneToMany, ManyToOne, JoinColumn } from 'typeorm';
import { Occupancy } from './occupancy.entity';
import { RoomExpense } from './room-expense.entity';
import { Department } from './department.entity';
@Entity('rooms')
export class Room {
@@ -44,10 +43,4 @@ export class Room {
@OneToMany(() => RoomExpense, (e) => e.room)
roomExpenses: RoomExpense[];
@Column({ name: 'department_id', type: 'integer', nullable: true })
departmentId: number;
@ManyToOne(() => Department, { nullable: true })
@JoinColumn({ name: 'department_id' })
department: Department;
}

View File

@@ -8,7 +8,6 @@ import {
JoinColumn,
} from 'typeorm';
import { Student } from './student.entity';
import { Department } from './department.entity';
@Entity('student_enrollments')
export class StudentEnrollment {
@@ -46,12 +45,6 @@ export class StudentEnrollment {
@Column({ length: 20, default: 'active' })
status: string;
@Column({ name: 'department_id', type: 'integer', nullable: true })
departmentId: number;
@ManyToOne(() => Department, { nullable: true })
@JoinColumn({ name: 'department_id' })
department: Department;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;

View File

@@ -8,7 +8,6 @@ import {
JoinColumn,
} from 'typeorm';
import { Student } from './student.entity';
import { Department } from './department.entity';
@Entity('student_profiles')
export class StudentProfile {
@@ -43,12 +42,6 @@ export class StudentProfile {
@Column({ type: 'text', nullable: true })
notes: string;
@Column({ name: 'department_id', type: 'integer', nullable: true })
departmentId: number;
@ManyToOne(() => Department, { nullable: true })
@JoinColumn({ name: 'department_id' })
department: Department;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;

View File

@@ -8,7 +8,6 @@ import {
JoinColumn,
} from 'typeorm';
import { Student } from './student.entity';
import { Department } from './department.entity';
@Entity('student_reports')
export class StudentReport {
@@ -34,12 +33,6 @@ export class StudentReport {
@Column({ name: 'generated_at', type: 'datetime', nullable: true })
generatedAt: Date;
@Column({ name: 'department_id', type: 'integer', nullable: true })
departmentId: number;
@ManyToOne(() => Department, { nullable: true })
@JoinColumn({ name: 'department_id' })
department: Department;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;

View File

@@ -14,7 +14,6 @@ import { PersonalExpense } from './personal-expense.entity';
import { Bill } from './bill.entity';
import { Tenant } from './tenant.entity';
import { User } from './user.entity';
import { Department } from './department.entity';
@Entity('students')
export class Student {
@@ -82,10 +81,4 @@ export class Student {
@OneToMany(() => Bill, (b) => b.student)
bills: Bill[];
@Column({ name: 'department_id', type: 'integer', nullable: true })
departmentId: number;
@ManyToOne(() => Department, { nullable: true })
@JoinColumn({ name: 'department_id' })
department: Department;
}

View File

@@ -1,38 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Unique,
} from 'typeorm';
import { User } from './user.entity';
import { Department } from './department.entity';
@Entity('user_departments')
@Unique(['userId', 'departmentId'])
export class UserDepartment {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: 'user_id', type: 'integer' })
userId: number;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
@Column({ name: 'department_id', type: 'integer' })
departmentId: number;
@ManyToOne(() => Department, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'department_id' })
department: Department;
@Column({ name: 'is_default', type: 'boolean', default: false })
isDefault: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}

View File

@@ -11,7 +11,7 @@ import {
BatchRoomExpenseDto,
} from './dto/expense.dto';
import { RoomsService } from '../rooms/rooms.service';
import { CampusScope } from '../common/campus-scope';
@Injectable()
export class ExpensesService {
@@ -20,7 +20,6 @@ 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,
) {}
// 宿舍费用
@@ -28,14 +27,10 @@ export class ExpensesService {
const room = await this.roomRepo.findOne({ where: { id: dto.roomId } });
if (!room) throw new NotFoundException('宿舍不存在');
const entity = this.roomExpRepo.create({ ...dto, recordedBy: userId });
entity.departmentId = room.departmentId;
return this.roomExpRepo.save(entity);
}
async batchCreateRoomExpenses(dto: BatchRoomExpenseDto, userId?: number) {
const roomIds = [...new Set(dto.expenses.map((e) => e.roomId))];
const rooms = await this.roomRepo.find({ where: { id: In(roomIds) } });
const roomDeptMap = new Map(rooms.map((r) => [r.id, r.departmentId]));
const entities = dto.expenses.map((e) => {
const entity = this.roomExpRepo.create({
roomId: e.roomId,
@@ -46,19 +41,16 @@ export class ExpensesService {
periodEnd: dto.periodEnd,
recordedBy: userId,
});
entity.departmentId = roomDeptMap.get(e.roomId)!;
return entity;
});
return this.roomExpRepo.save(entities);
}
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 });
@@ -94,14 +86,12 @@ export class ExpensesService {
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
if (!student) throw new NotFoundException('学生不存在');
const entity = this.personalExpRepo.create({ ...dto, recordedBy: userId });
entity.departmentId = student.departmentId;
return this.personalExpRepo.save(entity);
}
async findPersonalExpenses(query?: { studentId?: number }) {
let where: Record<string, unknown> = {};
const where: Record<string, unknown> = {};
if (query?.studentId) where.studentId = query.studentId;
where = await this.scope.filter(where);
return this.personalExpRepo.find({
where,
relations: ['student'],

View File

@@ -3,13 +3,11 @@
*
* 提供:
* - OAuth2 access_token新版 API + 缓存)
* - BFS 遍历所有部门 + 用户(带限流)
* - 用户同步(自动建 Student + StudentDingMapping
*/
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Department } from '../entities/department.entity';
import { Student } from '../entities/student.entity';
import { StudentDingMapping } from '../entities/student-ding-mapping.entity';
@@ -20,18 +18,6 @@ interface DingTalkTokenResponse {
expireIn: number;
}
interface SubDeptIdListResponse {
errcode: number;
errmsg: string;
result: { dept_id_list: number[] };
}
interface DepartmentDetailResponse {
errcode: number;
errmsg: string;
result: { dept_id: number; name: string; parent_id: number };
}
interface DingTalkUserListResponse {
errcode: number;
errmsg: string;
@@ -61,28 +47,6 @@ export interface DingTalkAttendanceResult {
checkType: string;
}
/** 钉钉部门树节点,供前端选择器使用 */
export interface DingOrgTreeNode {
id: number;
name: string;
parentId: number;
children: DingOrgTreeNode[];
}
/** 钉钉部门树节点(含用户),供同步用户选择器使用 */
export interface DingOrgTreeNodeWithUsers {
id: number;
name: string;
parentId: number;
children: DingOrgTreeNodeWithUsers[];
users: Array<{
userid: string;
name: string;
mobile: string;
deptIds: number[];
}>;
}
// ── 考勤排班 API 类型 ──
/** 班次卡段打卡时间 */
@@ -182,8 +146,6 @@ export class DingTalkService {
private static readonly MIN_INTERVAL = 1000 / DingTalkService.RATE_LIMIT;
constructor(
@InjectRepository(Department)
private readonly deptRepo: Repository<Department>,
@InjectRepository(Student)
private readonly studentRepo: Repository<Student>,
@InjectRepository(StudentDingMapping)
@@ -222,65 +184,6 @@ export class DingTalkService {
return this.accessToken;
}
// ═══════════════════════════════════════════
// Department BFS — 对齐 gongxue-dorm-sys getAllSubDepartmentIds
// ═══════════════════════════════════════════
private async getAllDeptIds(token: string, rootDeptId = 1): Promise<number[]> {
const ids: number[] = [];
const queue: number[] = [rootDeptId];
while (queue.length > 0) {
const deptId = queue.shift()!;
ids.push(deptId);
try {
await this.rateLimit();
const res = await fetch(
`https://oapi.dingtalk.com/topapi/v2/department/listsubid?access_token=${token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dept_id: deptId }),
},
);
const body: SubDeptIdListResponse = await res.json();
if (body.errcode === 0 && body.result?.dept_id_list) {
queue.push(...body.result.dept_id_list);
}
} catch (e) {
this.logger.error(`获取部门 ${deptId} 子部门失败: ${(e as Error).message}`);
}
}
return ids;
}
// ═══════════════════════════════════════════
// Department detail
// ═══════════════════════════════════════════
private async getDeptDetail(
token: string,
deptId: number,
): Promise<{ dept_id: number; name: string; parent_id: number } | null> {
try {
const res = await fetch(
`https://oapi.dingtalk.com/topapi/v2/department/get?access_token=${token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dept_id: deptId, language: 'zh_CN' }),
},
);
const body: DepartmentDetailResponse = await res.json();
return body.errcode === 0 ? body.result : null;
} catch (e) {
this.logger.error(`获取部门 ${deptId} 详情失败: ${(e as Error).message}`);
return null;
}
}
// ═══════════════════════════════════════════
// Users by department — 对齐 gongxue-dorm-sys getUsersByDepartment
// ═══════════════════════════════════════════
@@ -334,191 +237,39 @@ export class DingTalkService {
const t0 = Date.now();
const token = await this.getAccessToken();
// ── Step 1: BFS traverse all departments ──
this.logger.log('开始 BFS 遍历钉钉部门...');
const deptIds = await this.getAllDeptIds(token, rootDeptId);
this.logger.log(`共发现 ${deptIds.length} 个部门`);
// ── Step 2: Sync departments ──
let deptCount = 0;
for (let i = 0; i < deptIds.length; i++) {
const deptId = deptIds[i];
if (i > 0) await this.delay(i);
const detail = await this.getDeptDetail(token, deptId);
if (!detail) continue;
const sourceId = String(detail.dept_id);
let dept = await this.deptRepo.findOne({ where: { source: 'dingtalk', sourceId } });
if (dept) {
dept.name = detail.name;
if (detail.parent_id) dept.parentSourceId = String(detail.parent_id);
} else {
dept = this.deptRepo.create({
name: detail.name,
source: 'dingtalk',
sourceId,
type: 'department',
} as Department);
if (detail.parent_id) dept.parentSourceId = String(detail.parent_id);
deptCount++;
}
await this.deptRepo.save(dept);
}
// Set parent relationships
const syncedDepts = await this.deptRepo.find({ where: { source: 'dingtalk' } });
const idMap = new Map(syncedDepts.map((d) => [d.sourceId, d.id]));
for (const dept of syncedDepts) {
if (dept.parentSourceId && idMap.has(dept.parentSourceId)) {
dept.parentId = idMap.get(dept.parentSourceId)!;
} else if (dept.parentSourceId === '1' || dept.parentSourceId === '0') {
dept.parentId = undefined as any;
}
}
await this.deptRepo.save(syncedDepts);
// ── Step 3: Sync users per department ──
// ── Sync users from root department ──
let userCount = 0;
const seenUserIds = new Set<string>();
for (let i = 0; i < deptIds.length; i++) {
const deptId = deptIds[i];
const dingUsers = await this.getDeptUsers(token, deptId);
const dingUsers = await this.getDeptUsers(token, rootDeptId);
for (const du of dingUsers) {
if (seenUserIds.has(du.userid)) continue;
seenUserIds.add(du.userid);
for (const du of dingUsers) {
if (seenUserIds.has(du.userid)) continue;
seenUserIds.add(du.userid);
await this.syncOneUser(du);
userCount++;
}
await this.syncOneUser(du);
userCount++;
}
this.logger.log(
`钉钉同步完成: ${deptCount} 个新部门, ${userCount} 个用户, API 请求 ${this.apiRequestCount} 次, 耗时 ${Date.now() - t0}ms`,
`钉钉同步完成: ${userCount} 个用户, API 请求 ${this.apiRequestCount} 次, 耗时 ${Date.now() - t0}ms`,
);
return { deptCount, userCount };
return { deptCount: 0, userCount };
}
/**
* 获取钉钉组织部门树(只含部门,不含用户),供前端选择同步起点
* 返回从指定 rootDeptId 开始的树;默认根部门 1。
* 获取钉钉组织部门树(只含部门,不含用户)。
* ponytail: Department entity removed; returns empty array.
*/
async fetchOrgTree(rootDeptId = 1): Promise<DingOrgTreeNode[]> {
if (!this.configured) {
throw new ServiceUnavailableException('钉钉未配置');
}
const token = await this.getAccessToken();
const deptIds = await this.getAllDeptIds(token, rootDeptId);
// 拉每个部门详情
const nodes: DingOrgTreeNode[] = [];
for (let i = 0; i < deptIds.length; i++) {
if (i > 0) await this.delay(i);
const detail = await this.getDeptDetail(token, deptIds[i]);
if (detail) {
nodes.push({
id: detail.dept_id,
name: detail.name,
parentId: detail.parent_id,
children: [],
});
}
}
// 组装成树
const map = new Map<number, DingOrgTreeNode>();
nodes.forEach((n) => map.set(n.id, n));
const roots: DingOrgTreeNode[] = [];
for (const node of nodes) {
const parent = map.get(node.parentId);
if (parent && node.id !== rootDeptId) {
parent.children.push(node);
} else {
roots.push(node);
}
}
return roots;
async fetchOrgTree(_rootDeptId = 1): Promise<[]> {
return [];
}
/**
* 获取钉钉组织部门树(含用户),供前端同步用户选择器使用
* 返回从指定 rootDeptId 开始的树,每个部门节点含 users 数组。
* 获取钉钉组织部门树(含用户)。
* ponytail: Department entity removed; returns empty array.
*/
async fetchOrgTreeWithUsers(rootDeptId = 1): Promise<DingOrgTreeNodeWithUsers[]> {
if (!this.configured) {
throw new ServiceUnavailableException('钉钉未配置');
}
const token = await this.getAccessToken();
const deptIds = await this.getAllDeptIds(token, rootDeptId);
// 拉每个部门详情
const nodes: DingOrgTreeNodeWithUsers[] = [];
// Before dedup: collect deptIds per user
const userDeptMap = new Map<string, number[]>();
for (let i = 0; i < deptIds.length; i++) {
if (i > 0) await this.delay(i);
const detail = await this.getDeptDetail(token, deptIds[i]);
if (!detail) continue;
// 拉该部门下的用户
const dingUsers = await this.getDeptUsers(token, deptIds[i]);
this.logger.log(`[dingtalk] dept ${deptIds[i]} (${detail.name}): ${dingUsers.length} users`);
nodes.push({
id: detail.dept_id,
name: detail.name,
parentId: detail.parent_id,
children: [],
users: dingUsers.map((u) => ({
userid: u.userid,
name: u.name,
mobile: u.mobile,
deptIds: [],
})),
});
// Record which departments each user belongs to
for (const u of dingUsers) {
if (!userDeptMap.has(u.userid)) {
userDeptMap.set(u.userid, []);
}
userDeptMap.get(u.userid)!.push(detail.dept_id);
}
}
// 全局去重:同一个 dingUserId 可能在多个部门出现
const seenUserIds = new Set<string>();
for (const node of nodes) {
node.users = node.users
.filter((u) => {
if (seenUserIds.has(u.userid)) return false;
seenUserIds.add(u.userid);
return true;
})
.map((u) => ({
...u,
deptIds: userDeptMap.get(u.userid) || [],
}));
}
// 组装成树(父节点可能已被过滤,缺失的父节点 → 节点提升为根)
const map = new Map<number, DingOrgTreeNodeWithUsers>();
nodes.forEach((n) => map.set(n.id, n));
const roots: DingOrgTreeNodeWithUsers[] = [];
for (const node of nodes) {
const parent = map.get(node.parentId);
if (parent && node.id !== rootDeptId) {
parent.children.push(node);
} else {
roots.push(node);
}
}
return roots;
async fetchOrgTreeWithUsers(_rootDeptId = 1): Promise<[]> {
return [];
}
// ═══════════════════════════════════════════

View File

@@ -1,11 +1,11 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Department, User, Student, StudentDingMapping, Class } from '../entities';
import { User, Student, StudentDingMapping, Class } from '../entities';
import { DingTalkService } from './dingtalk.service';
import { WeComService } from './wecom.service';
@Module({
imports: [TypeOrmModule.forFeature([Department, User, Student, StudentDingMapping, Class])],
imports: [TypeOrmModule.forFeature([User, Student, StudentDingMapping, Class])],
providers: [DingTalkService, WeComService],
exports: [DingTalkService, WeComService],
})

View File

@@ -1,7 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Department } from '../entities/department.entity';
import { User } from '../entities/user.entity';
interface WeComTokenResponse {
@@ -35,8 +34,6 @@ export class WeComService {
private tokenExpiresAt = 0;
constructor(
@InjectRepository(Department)
private readonly deptRepo: Repository<Department>,
@InjectRepository(User)
private readonly userRepo: Repository<User>,
) {}
@@ -97,45 +94,14 @@ export class WeComService {
return body.userlist;
}
async syncAll(): Promise<{ deptCount: number; userCount: number }> {
async syncAll(): Promise<{ userCount: number }> {
if (!this.configured) {
this.logger.warn('WeCom not configured (WECOM_CORP_ID / WECOM_CORP_SECRET missing), skipping sync');
return { deptCount: 0, userCount: 0 };
return { userCount: 0 };
}
const token = await this.getAccessToken();
const wxDepts = await this.fetchDepartments(token);
let deptCount = 0;
for (const wd of wxDepts) {
const sourceId = String(wd.id);
let dept = await this.deptRepo.findOne({ where: { source: 'wecom', sourceId } });
if (dept) {
dept.name = wd.name;
dept.parentSourceId = (wd.parentid ? String(wd.parentid) : undefined) as any;
} else {
dept = this.deptRepo.create({
name: wd.name,
source: 'wecom',
sourceId,
parentSourceId: (wd.parentid ? String(wd.parentid) : undefined),
type: 'department',
});
deptCount++;
}
await this.deptRepo.save(dept);
}
const syncedDepts = await this.deptRepo.find({ where: { source: 'wecom' } });
const idMap = new Map(syncedDepts.map((d) => [d.sourceId, d.id]));
for (const dept of syncedDepts) {
if (dept.parentSourceId && idMap.has(dept.parentSourceId)) {
dept.parentId = idMap.get(dept.parentSourceId)!;
} else if (dept.parentSourceId === '0' || dept.parentSourceId === '1') {
dept.parentId = undefined as any;
}
}
await this.deptRepo.save(syncedDepts);
let userCount = 0;
const seenUserIds = new Set<string>();
for (const wd of wxDepts) {
@@ -159,7 +125,7 @@ export class WeComService {
}
}
this.logger.log(`WeCom sync done: ${deptCount} new depts, ${userCount} new users`);
return { deptCount, userCount };
this.logger.log(`WeCom sync done: ${userCount} new users`);
return { userCount };
}
}

View File

@@ -1,16 +1,12 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { AppModule } from './app.module';
import { seedDefaultCampus } from './departments/seed';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api');
app.enableCors();
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
const dataSource = app.get(DataSource);
await seedDefaultCampus(dataSource);
await app.listen(process.env.PORT ?? 3000);
console.log(`Server running on http://localhost:${process.env.PORT ?? 3000}`);
}

View File

@@ -17,7 +17,7 @@ import { Locker } from '../entities/locker.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 {
@@ -29,7 +29,6 @@ export class OccupanciesService {
@InjectRepository(Bed) private bedRepo: Repository<Bed>,
@InjectRepository(Locker) private lockerRepo: Repository<Locker>,
private dataSource: DataSource,
private readonly scope: CampusScope,
) {}
async findAll(query?: { roomId?: number; studentId?: number; active?: boolean }) {
@@ -40,10 +39,6 @@ export class OccupanciesService {
.leftJoinAndSelect('o.bed', 'bed')
.leftJoinAndSelect('o.locker', 'locker')
.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');
@@ -97,7 +92,6 @@ export class OccupanciesService {
bedId: dto.bedId,
lockerId: dto.lockerId,
});
occ.departmentId = room.departmentId;
const saved = await this.repo.save(occ);
// 更新床位/柜子状态

View File

@@ -28,9 +28,6 @@ export class CreateRoomDto {
@IsNumber()
monthlyRate?: number;
@IsOptional()
@IsInt()
departmentId?: number;
}
export class UpdateRoomDto {

View File

@@ -360,8 +360,7 @@ export class RoomsController {
monthlyRate,
});
});
const departmentId = req.headers?.['x-campus-id'] ? parseInt(String(req.headers['x-campus-id']), 10) || undefined : undefined;
const result = await this.service.batchImport(rows, departmentId);
const result = await this.service.batchImport(rows);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,

View File

@@ -1,7 +1,7 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Like, IsNull, Not, In, LessThanOrEqual, MoreThanOrEqual } 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';
@@ -17,7 +17,6 @@ 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,
@InjectRepository(Bed) private bedRepo: Repository<Bed>,
@InjectRepository(Locker) private lockerRepo: Repository<Locker>,
) {}
@@ -69,8 +68,7 @@ export class RoomsService {
const where: any = {};
if (query?.building) where.building = query.building;
if (!query?.includeArchived) where.status = Not('archived');
const filteredWhere = await this.scope.filter(where);
return this.repo.find({ where: filteredWhere, order: { roomNumber: 'ASC' } });
return this.repo.find({ where, order: { roomNumber: 'ASC' } });
}
async findOne(id: number) {
@@ -92,8 +90,7 @@ export class RoomsService {
async getRoomOverview(query?: { includeArchived?: boolean }) {
const where: any = {};
if (!query?.includeArchived) where.status = Not('archived');
const filteredWhere = await this.scope.filter(where);
const rooms = await this.repo.find({ where: filteredWhere, order: { building: 'ASC', roomNumber: 'ASC' } });
const rooms = await this.repo.find({ where, order: { building: 'ASC', roomNumber: 'ASC' } });
const result: any[] = [];
for (const room of rooms) {
const count = await this.occRepo.count({
@@ -113,7 +110,6 @@ export class RoomsService {
roomType: dto.roomType ?? parsed.roomType,
capacity: dto.capacity ?? parsed.capacity,
});
if (dto.departmentId) entity.departmentId = dto.departmentId;
return this.repo.save(entity);
}
@@ -184,20 +180,17 @@ export class RoomsService {
// 实时视图排除已归档房间;历史视图不排除——当时有人住的房间即使现在已归档也应显示。
const rooms = await this.repo.find({
where: await this.scope.filter(isHistorical ? {} : { status: Not('archived') }),
where: isHistorical ? {} : { status: Not('archived') },
order: { building: 'ASC', roomNumber: 'ASC' },
});
// scope.filter() produces identical scope conditions within the same request;
// extract once and spread to avoid redundant calls.
const scopeWhere = await this.scope.filter({});
const occupancies = await this.occRepo.find({
where: isHistorical
? [
{ ...scopeWhere, checkInDate: LessThanOrEqual(targetDate), checkOutDate: IsNull() },
{ ...scopeWhere, checkInDate: LessThanOrEqual(targetDate), checkOutDate: MoreThanOrEqual(targetDate) },
{ checkInDate: LessThanOrEqual(targetDate), checkOutDate: IsNull() },
{ checkInDate: LessThanOrEqual(targetDate), checkOutDate: MoreThanOrEqual(targetDate) },
]
: { ...scopeWhere, checkOutDate: IsNull() },
: { checkOutDate: IsNull() },
relations: ['student', 'tenant'],
order: { checkInDate: 'ASC' },
});
@@ -297,7 +290,6 @@ export class RoomsService {
rentalCategory?: string;
monthlyRate?: number;
}[],
departmentId?: number,
) {
let imported = 0;
let skipped = 0;
@@ -322,7 +314,6 @@ export class RoomsService {
roomType: row.roomType || parsed.roomType || undefined,
rentalCategory: row.rentalCategory || undefined,
monthlyRate: row.monthlyRate ?? undefined,
departmentId: departmentId ?? undefined,
}),
);
imported++;

View File

@@ -6,7 +6,6 @@ import { SchedulesService } from './schedules.service';
import { ClassSchedule, ScheduleType } from '../entities/class-schedule.entity';
import { ClassroomRental } from '../entities/classroom-rental.entity';
import { Class } from '../entities/class.entity';
import { CampusScope } from '../common/campus-scope';
/** Build a mock query-builder where each chain method returns `this`. */
function mockQueryBuilder<T>(results: T[] = []) {
@@ -36,7 +35,6 @@ describe('SchedulesService — checkConflict', () => {
{ provide: getRepositoryToken(ClassSchedule), useValue: mockRepo },
{ provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } },
{ provide: getRepositoryToken(ClassroomRental), useValue: { createQueryBuilder: jest.fn() } },
{ provide: CampusScope, useValue: { getScopeDepartmentIds: jest.fn().mockResolvedValue(null), filter: jest.fn((w: unknown) => w) } },
],
}).compile();
@@ -138,13 +136,6 @@ describe('SchedulesService — getClassroomOccupancy', () => {
{ provide: getRepositoryToken(ClassSchedule), useValue: { createQueryBuilder: jest.fn() } },
{ provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } },
{ provide: getRepositoryToken(ClassroomRental), useValue: { createQueryBuilder: jest.fn() } },
{
provide: CampusScope,
useValue: {
getScopeDepartmentIds: jest.fn().mockResolvedValue(null),
filter: jest.fn((w: unknown) => w),
},
},
],
}).compile();

View File

@@ -2,7 +2,7 @@ import { Injectable, NotFoundException, ConflictException } from '@nestjs/common
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ClassSchedule, Class, ClassroomRental } from '../entities';
import { CampusScope } from '../common/campus-scope';
import {
CreateScheduleDto,
UpdateScheduleDto,
@@ -15,7 +15,6 @@ export class SchedulesService {
constructor(
@InjectRepository(ClassSchedule)
private readonly scheduleRepo: Repository<ClassSchedule>,
private readonly scope: CampusScope,
@InjectRepository(Class) private readonly classRepo: Repository<Class>,
@InjectRepository(ClassroomRental)
private readonly rentalRepo: Repository<ClassroomRental>,
@@ -23,10 +22,6 @@ export class SchedulesService {
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 });
@@ -50,9 +45,6 @@ export class SchedulesService {
await this.checkConflict(dto.classroomId, dto.weekDay, dto.startTime, dto.endTime, dto.startDate, dto.endDate);
const schedule = this.scheduleRepo.create(dto);
if (dto.departmentId) {
schedule.departmentId = dto.departmentId;
}
const saved = await this.scheduleRepo.save(schedule);
return this.findOne(saved.id);
}
@@ -130,10 +122,6 @@ 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

@@ -3,14 +3,14 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcryptjs';
import {
Department, DepartmentType, User, Role, Permission,
User, Role, Permission,
Tenant, Student, Room, Classroom, Occupancy,
RoomExpense, ExpenseType, Class,
ClassStudent, ClassTeacher, ClassSchedule,
Bill, BillItem, Deposit,
AttendanceRecord, ClassroomRental,
StudentProfile, StudentEnrollment, ExamScore,
LearningRecord, UserDepartment, TeacherRoleType,
LearningRecord, TeacherRoleType,
ClassType, ClassStatus, ScheduleType,
} from '../entities';
@@ -29,7 +29,7 @@ function pick<T>(arr: T[]): T {
@Injectable()
export class SeedDevService {
private readonly logger = new Logger(SeedDevService.name);
private deptId = 1;
private cachedStudents: Student[] = [];
private cachedRooms: Room[] = [];
private cachedClassrooms: Classroom[] = [];
@@ -38,11 +38,9 @@ export class SeedDevService {
private cachedTenants: Tenant[] = [];
constructor(
@InjectRepository(Department) private deptRepo: Repository<Department>,
@InjectRepository(Permission) private permRepo: Repository<Permission>,
@InjectRepository(Role) private roleRepo: Repository<Role>,
@InjectRepository(User) private userRepo: Repository<User>,
@InjectRepository(UserDepartment) private userDeptRepo: Repository<UserDepartment>,
@InjectRepository(Tenant) private tenantRepo: Repository<Tenant>,
@InjectRepository(Student) private studentRepo: Repository<Student>,
@InjectRepository(Room) private roomRepo: Repository<Room>,
@@ -77,11 +75,11 @@ export class SeedDevService {
// ═══════════════════ Layer 0 ══════════════════════════
await this.seedPermissions();
await this.seedExpenseTypes();
await this.seedDepartments();
// ═══════════════════ Layer 1 ══════════════════════════
await this.seedRoles();
await this.seedUsersAndDepartments();
await this.seedUsers();
// ═══════════════════ Layer 2 ══════════════════════════
await this.seedTenants();
@@ -173,33 +171,7 @@ export class SeedDevService {
this.logger.log(`${types.length} expense types`);
}
// ── 0c: departments ──────────────────────────────────
private async seedDepartments(): Promise<void> {
// Reuse existing campus if seedDefaultCampus() already created it in main.ts
let campus = await this.deptRepo.findOne({ where: { type: DepartmentType.CAMPUS } });
if (!campus) {
campus = await this.deptRepo.save({
name: '主校区',
type: DepartmentType.CAMPUS,
sortOrder: 0,
});
}
const subCount = await this.deptRepo.count({ where: { parentId: campus.id } });
if (subCount === 0) {
await this.deptRepo.save([
{ name: '教务部', type: DepartmentType.DEPARTMENT, parentId: campus.id, sortOrder: 1 },
{ name: '宿管部', type: DepartmentType.DEPARTMENT, parentId: campus.id, sortOrder: 2 },
{ name: '财务部', type: DepartmentType.DEPARTMENT, parentId: campus.id, sortOrder: 3 },
{ name: '恭学专升本', type: DepartmentType.DEPARTMENT, parentId: campus.id, sortOrder: 4 },
{ name: '26定向', type: DepartmentType.DEPARTMENT, parentId: campus.id, sortOrder: 5 },
{ name: '续住', type: DepartmentType.DEPARTMENT, parentId: campus.id, sortOrder: 6 },
]);
}
this.deptId = campus.id;
const total = await this.deptRepo.count();
this.logger.log(`${total} departments`);
}
// ── 1a: roles ────────────────────────────────────────
@@ -236,9 +208,9 @@ export class SeedDevService {
this.logger.log(`${roles.length} roles`);
}
// ── 1b: users + user_departments ─────────────────────
// ── 1b: users ───────────────────────────────────────
private async seedUsersAndDepartments(): Promise<void> {
private async seedUsers(): Promise<void> {
const hash = await bcrypt.hash('123456', 10);
const roles = await this.roleRepo.find();
const superAdminRole = roles.find((r) => r.name === '超管');
@@ -263,7 +235,6 @@ export class SeedDevService {
roles: u.roles,
});
saved.push(user);
await this.userDeptRepo.save({ userId: user.id, departmentId: this.deptId, isDefault: true });
}
// Load all users for later seed steps to reference
this.cachedUsers = await this.userRepo.find();
@@ -285,36 +256,36 @@ export class SeedDevService {
private async seedRooms(): Promise<void> {
// Real data pattern: 单人间 (2号楼5层) + 四人间 (3/4/5/6号楼1-2层)
const rooms: Array<{ roomNumber: string; building: string; floor: number; capacity: number; status: string; roomType: string; gender: string; rentalCategory: string; monthlyRate: number; departmentId: number }> = [
const rooms: Array<{ roomNumber: string; building: string; floor: number; capacity: number; status: string; roomType: string; gender: string; rentalCategory: string; monthlyRate: number }> = [
// 2号楼5层 单人间 pattern (real data: 2-502 through 2-519)
{ roomNumber: '2-502', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800, departmentId: this.deptId },
{ roomNumber: '2-503', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800, departmentId: this.deptId },
{ roomNumber: '2-504', building: '2号楼', floor: 5, capacity: 1, status: 'available', roomType: '单人间', gender: '男', rentalCategory: 'long', monthlyRate: 800, departmentId: this.deptId },
{ roomNumber: '2-505', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '男', rentalCategory: 'long', monthlyRate: 800, departmentId: this.deptId },
{ roomNumber: '2-506', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800, departmentId: this.deptId },
{ roomNumber: '2-507', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800, departmentId: this.deptId },
{ roomNumber: '2-508', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800, departmentId: this.deptId },
{ roomNumber: '2-509', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800, departmentId: this.deptId },
{ roomNumber: '2-510', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800, departmentId: this.deptId },
{ roomNumber: '2-511', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '男', rentalCategory: 'long', monthlyRate: 800, departmentId: this.deptId },
{ roomNumber: '2-512', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800, departmentId: this.deptId },
{ roomNumber: '2-513', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800, departmentId: this.deptId },
{ roomNumber: '2-515', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800, departmentId: this.deptId },
{ roomNumber: '2-516', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800, departmentId: this.deptId },
{ roomNumber: '2-517', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800, departmentId: this.deptId },
{ roomNumber: '2-518', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800, departmentId: this.deptId },
{ roomNumber: '2-519', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800, departmentId: this.deptId },
{ roomNumber: '2-502', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800 },
{ roomNumber: '2-503', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800 },
{ roomNumber: '2-504', building: '2号楼', floor: 5, capacity: 1, status: 'available', roomType: '单人间', gender: '男', rentalCategory: 'long', monthlyRate: 800 },
{ roomNumber: '2-505', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '男', rentalCategory: 'long', monthlyRate: 800 },
{ roomNumber: '2-506', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800 },
{ roomNumber: '2-507', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800 },
{ roomNumber: '2-508', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800 },
{ roomNumber: '2-509', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800 },
{ roomNumber: '2-510', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800 },
{ roomNumber: '2-511', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '男', rentalCategory: 'long', monthlyRate: 800 },
{ roomNumber: '2-512', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800 },
{ roomNumber: '2-513', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800 },
{ roomNumber: '2-515', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800 },
{ roomNumber: '2-516', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800 },
{ roomNumber: '2-517', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800 },
{ roomNumber: '2-518', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800 },
{ roomNumber: '2-519', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800 },
// 1号楼 家庭房
{ roomNumber: '1-2-301', building: '1号楼', floor: 3, capacity: 2, status: 'full', roomType: '家庭房', gender: '男', rentalCategory: 'long', monthlyRate: 1200, departmentId: this.deptId },
{ roomNumber: '1-2-301', building: '1号楼', floor: 3, capacity: 2, status: 'full', roomType: '家庭房', gender: '男', rentalCategory: 'long', monthlyRate: 1200 },
// 四人间 pattern
{ roomNumber: '3-106', building: '3号楼', floor: 1, capacity: 4, status: 'available', roomType: '四人间', gender: '女', rentalCategory: 'long', monthlyRate: 1200, departmentId: this.deptId },
{ roomNumber: '3-107', building: '3号楼', floor: 1, capacity: 4, status: 'full', roomType: '四人间', gender: '女', rentalCategory: 'long', monthlyRate: 1200, departmentId: this.deptId },
{ roomNumber: '4-107', building: '4号楼', floor: 1, capacity: 4, status: 'available', roomType: '四人间', gender: '男', rentalCategory: 'long', monthlyRate: 1200, departmentId: this.deptId },
{ roomNumber: '4-111', building: '4号楼', floor: 1, capacity: 4, status: 'available', roomType: '四人间', gender: '男', rentalCategory: 'long', monthlyRate: 1200, departmentId: this.deptId },
{ roomNumber: '4-201', building: '4号楼', floor: 2, capacity: 4, status: 'available', roomType: '四人间', gender: '男', rentalCategory: 'long', monthlyRate: 1200, departmentId: this.deptId },
{ roomNumber: '4-204', building: '4号楼', floor: 2, capacity: 4, status: 'available', roomType: '四人间', gender: '男', rentalCategory: 'long', monthlyRate: 1200, departmentId: this.deptId },
{ roomNumber: '5-109', building: '5号楼', floor: 1, capacity: 4, status: 'available', roomType: '四人间', gender: '女', rentalCategory: 'long', monthlyRate: 1200, departmentId: this.deptId },
{ roomNumber: '6-116', building: '6号楼', floor: 1, capacity: 4, status: 'available', roomType: '四人间', gender: '男', rentalCategory: 'long', monthlyRate: 1200, departmentId: this.deptId },
{ roomNumber: '3-106', building: '3号楼', floor: 1, capacity: 4, status: 'available', roomType: '四人间', gender: '女', rentalCategory: 'long', monthlyRate: 1200 },
{ roomNumber: '3-107', building: '3号楼', floor: 1, capacity: 4, status: 'full', roomType: '四人间', gender: '女', rentalCategory: 'long', monthlyRate: 1200 },
{ roomNumber: '4-107', building: '4号楼', floor: 1, capacity: 4, status: 'available', roomType: '四人间', gender: '男', rentalCategory: 'long', monthlyRate: 1200 },
{ roomNumber: '4-111', building: '4号楼', floor: 1, capacity: 4, status: 'available', roomType: '四人间', gender: '男', rentalCategory: 'long', monthlyRate: 1200 },
{ roomNumber: '4-201', building: '4号楼', floor: 2, capacity: 4, status: 'available', roomType: '四人间', gender: '男', rentalCategory: 'long', monthlyRate: 1200 },
{ roomNumber: '4-204', building: '4号楼', floor: 2, capacity: 4, status: 'available', roomType: '四人间', gender: '男', rentalCategory: 'long', monthlyRate: 1200 },
{ roomNumber: '5-109', building: '5号楼', floor: 1, capacity: 4, status: 'available', roomType: '四人间', gender: '女', rentalCategory: 'long', monthlyRate: 1200 },
{ roomNumber: '6-116', building: '6号楼', floor: 1, capacity: 4, status: 'available', roomType: '四人间', gender: '男', rentalCategory: 'long', monthlyRate: 1200 },
];
const saved = await this.roomRepo.save(rooms);
@@ -326,10 +297,10 @@ export class SeedDevService {
private async seedClassrooms(): Promise<void> {
const classrooms = [
{ name: '102', building: 'a座', floor: 1, capacity: 30, roomType: '大', supervisor: '陈浩', departmentId: this.deptId },
{ name: '201', building: 'a座', floor: 2, capacity: 30, roomType: '大', supervisor: '刘老师', departmentId: this.deptId },
{ name: '202', building: 'a座', floor: 2, capacity: 25, roomType: '次大', supervisor: '刘老师', departmentId: this.deptId },
{ name: '301', building: 'b座', floor: 3, capacity: 20, roomType: '小', supervisor: '黄老师', departmentId: this.deptId },
{ name: '102', building: 'a座', floor: 1, capacity: 30, roomType: '大', supervisor: '陈浩' },
{ name: '201', building: 'a座', floor: 2, capacity: 30, roomType: '大', supervisor: '刘老师' },
{ name: '202', building: 'a座', floor: 2, capacity: 25, roomType: '次大', supervisor: '刘老师' },
{ name: '301', building: 'b座', floor: 3, capacity: 20, roomType: '小', supervisor: '黄老师' },
];
const saved = await this.classroomRepo.save(classrooms);
this.cachedClassrooms = saved;
@@ -340,38 +311,38 @@ export class SeedDevService {
private async seedStudents(): Promise<void> {
// Real data pattern: organization fields like 恭学专升本, 26定向, 续住, etc.
const students: Array<{ name: string; phone: string; studentNo: string; gender: string; ethnicity: string; organization: string; supervisor: string; departmentId: number; status: string }> = [
const students: Array<{ name: string; phone: string; studentNo: string; gender: string; ethnicity: string; organization: string; supervisor: string; status: string }> = [
// 26定向 students
{ name: '聂天羽', phone: '12345678912', studentNo: '123456', gender: '女', ethnicity: '汉', organization: '恭学专升本', supervisor: '', departmentId: this.deptId, status: 'active' },
{ name: '艾柯丽努尔·艾买尔江', phone: '', studentNo: '', gender: '女', ethnicity: '汉', organization: '26定向', supervisor: '', departmentId: this.deptId, status: 'active' },
{ name: '陈昊天', phone: '', studentNo: '', gender: '男', ethnicity: '汉', organization: '续住', supervisor: '糕糕', departmentId: this.deptId, status: 'active' },
{ name: '赵璟涵', phone: '', studentNo: '', gender: '女', ethnicity: '汉', organization: '26定向', supervisor: '', departmentId: this.deptId, status: 'active' },
{ name: '周子涵', phone: '', studentNo: '', gender: '女', ethnicity: '汉', organization: '26定向', supervisor: '', departmentId: this.deptId, status: 'active' },
{ name: '秦婧怡', phone: '', studentNo: '', gender: '女', ethnicity: '汉', organization: '26尊享+暑文', supervisor: '', departmentId: this.deptId, status: 'active' },
{ name: '石欣欣', phone: '', studentNo: '', gender: '女', ethnicity: '汉', organization: '26尊享', supervisor: '', departmentId: this.deptId, status: 'active' },
{ name: '李光铄', phone: '', studentNo: '', gender: '男', ethnicity: '汉', organization: '26定向', supervisor: '', departmentId: this.deptId, status: 'active' },
{ name: '田芸竹', phone: '12345678920', studentNo: '123464', gender: '女', ethnicity: '汉', organization: '恭学专升本', supervisor: '', departmentId: this.deptId, status: 'active' },
{ name: '荚欣语', phone: '12345678921', studentNo: '123465', gender: '女', ethnicity: '汉', organization: '恭学专升本', supervisor: '', departmentId: this.deptId, status: 'active' },
{ name: '刘倬宁', phone: '12345678922', studentNo: '123466', gender: '女', ethnicity: '汉', organization: '恭学专升本', supervisor: '', departmentId: this.deptId, status: 'active' },
{ name: '柴高星', phone: '', studentNo: '', gender: '女', ethnicity: '汉', organization: '26尊享+暑专+专冲', supervisor: '', departmentId: this.deptId, status: 'active' },
{ name: '焦怡菲', phone: '', studentNo: '', gender: '女', ethnicity: '汉', organization: '26尊享+暑期+专冲+年前文化', supervisor: '', departmentId: this.deptId, status: 'active' },
{ name: '王姿璇', phone: '', studentNo: '', gender: '女', ethnicity: '汉', organization: '26定向', supervisor: '', departmentId: this.deptId, status: 'active' },
{ name: '寇星彤', phone: '12345678926', studentNo: '123470', gender: '女', ethnicity: '汉', organization: '恭学专升本', supervisor: '', departmentId: this.deptId, status: 'active' },
{ name: '聂天羽', phone: '12345678912', studentNo: '123456', gender: '女', ethnicity: '汉', organization: '恭学专升本', supervisor: '', status: 'active' },
{ name: '艾柯丽努尔·艾买尔江', phone: '', studentNo: '', gender: '女', ethnicity: '汉', organization: '26定向', supervisor: '', status: 'active' },
{ name: '陈昊天', phone: '', studentNo: '', gender: '男', ethnicity: '汉', organization: '续住', supervisor: '糕糕', status: 'active' },
{ name: '赵璟涵', phone: '', studentNo: '', gender: '女', ethnicity: '汉', organization: '26定向', supervisor: '', status: 'active' },
{ name: '周子涵', phone: '', studentNo: '', gender: '女', ethnicity: '汉', organization: '26定向', supervisor: '', status: 'active' },
{ name: '秦婧怡', phone: '', studentNo: '', gender: '女', ethnicity: '汉', organization: '26尊享+暑文', supervisor: '', status: 'active' },
{ name: '石欣欣', phone: '', studentNo: '', gender: '女', ethnicity: '汉', organization: '26尊享', supervisor: '', status: 'active' },
{ name: '李光铄', phone: '', studentNo: '', gender: '男', ethnicity: '汉', organization: '26定向', supervisor: '', status: 'active' },
{ name: '田芸竹', phone: '12345678920', studentNo: '123464', gender: '女', ethnicity: '汉', organization: '恭学专升本', supervisor: '', status: 'active' },
{ name: '荚欣语', phone: '12345678921', studentNo: '123465', gender: '女', ethnicity: '汉', organization: '恭学专升本', supervisor: '', status: 'active' },
{ name: '刘倬宁', phone: '12345678922', studentNo: '123466', gender: '女', ethnicity: '汉', organization: '恭学专升本', supervisor: '', status: 'active' },
{ name: '柴高星', phone: '', studentNo: '', gender: '女', ethnicity: '汉', organization: '26尊享+暑专+专冲', supervisor: '', status: 'active' },
{ name: '焦怡菲', phone: '', studentNo: '', gender: '女', ethnicity: '汉', organization: '26尊享+暑期+专冲+年前文化', supervisor: '', status: 'active' },
{ name: '王姿璇', phone: '', studentNo: '', gender: '女', ethnicity: '汉', organization: '26定向', supervisor: '', status: 'active' },
{ name: '寇星彤', phone: '12345678926', studentNo: '123470', gender: '女', ethnicity: '汉', organization: '恭学专升本', supervisor: '', status: 'active' },
// 续住 students
{ name: '於嘉丽', phone: '', studentNo: '', gender: '女', ethnicity: '汉族', organization: '续住', supervisor: '泡泡', departmentId: this.deptId, status: 'active' },
{ name: '郑斌', phone: '', studentNo: '', gender: '男', ethnicity: '汉族', organization: '续住', supervisor: '糕糕', departmentId: this.deptId, status: 'active' },
{ name: '仵梓钰', phone: '', studentNo: '', gender: '女', ethnicity: '汉族', organization: '续住', supervisor: '方方', departmentId: this.deptId, status: 'active' },
{ name: '覃鼎浩', phone: '', studentNo: '', gender: '男', ethnicity: '汉族', organization: '续住', supervisor: '云熙', departmentId: this.deptId, status: 'active' },
{ name: '常智禹', phone: '', studentNo: '', gender: '男', ethnicity: '汉族', organization: '续住', supervisor: '方方', departmentId: this.deptId, status: 'active' },
{ name: '郭庆泉', phone: '', studentNo: '', gender: '男', ethnicity: '汉族', organization: '续住', supervisor: '糕糕', departmentId: this.deptId, status: 'active' },
{ name: '孙立欣', phone: '', studentNo: '', gender: '男', ethnicity: '汉族', organization: '续住', supervisor: '糕糕', departmentId: this.deptId, status: 'active' },
{ name: '韩尧祖', phone: '', studentNo: '', gender: '男', ethnicity: '汉族', organization: '', supervisor: '', departmentId: this.deptId, status: 'active' },
{ name: '陈亚津', phone: '', studentNo: '', gender: '女', ethnicity: '汉族', organization: '', supervisor: '', departmentId: this.deptId, status: 'active' },
{ name: '孟思妍', phone: '', studentNo: '', gender: '女', ethnicity: '汉族', organization: '26定向', supervisor: '', departmentId: this.deptId, status: 'active' },
{ name: '武嘉怡', phone: '', studentNo: '', gender: '女', ethnicity: '汉族', organization: '26定向', supervisor: '', departmentId: this.deptId, status: 'active' },
{ name: '刘禹含', phone: '', studentNo: '', gender: '女', ethnicity: '汉族', organization: '26定向', supervisor: '', departmentId: this.deptId, status: 'active' },
{ name: '杜瑾慧', phone: '', studentNo: '', gender: '女', ethnicity: '汉族', organization: '26定向', supervisor: '', departmentId: this.deptId, status: 'active' },
{ name: '孟凡志', phone: '', studentNo: '', gender: '男', ethnicity: '汉族', organization: '26定向', supervisor: '', departmentId: this.deptId, status: 'active' },
{ name: '於嘉丽', phone: '', studentNo: '', gender: '女', ethnicity: '汉族', organization: '续住', supervisor: '泡泡', status: 'active' },
{ name: '郑斌', phone: '', studentNo: '', gender: '男', ethnicity: '汉族', organization: '续住', supervisor: '糕糕', status: 'active' },
{ name: '仵梓钰', phone: '', studentNo: '', gender: '女', ethnicity: '汉族', organization: '续住', supervisor: '方方', status: 'active' },
{ name: '覃鼎浩', phone: '', studentNo: '', gender: '男', ethnicity: '汉族', organization: '续住', supervisor: '云熙', status: 'active' },
{ name: '常智禹', phone: '', studentNo: '', gender: '男', ethnicity: '汉族', organization: '续住', supervisor: '方方', status: 'active' },
{ name: '郭庆泉', phone: '', studentNo: '', gender: '男', ethnicity: '汉族', organization: '续住', supervisor: '糕糕', status: 'active' },
{ name: '孙立欣', phone: '', studentNo: '', gender: '男', ethnicity: '汉族', organization: '续住', supervisor: '糕糕', status: 'active' },
{ name: '韩尧祖', phone: '', studentNo: '', gender: '男', ethnicity: '汉族', organization: '', supervisor: '', status: 'active' },
{ name: '陈亚津', phone: '', studentNo: '', gender: '女', ethnicity: '汉族', organization: '', supervisor: '', status: 'active' },
{ name: '孟思妍', phone: '', studentNo: '', gender: '女', ethnicity: '汉族', organization: '26定向', supervisor: '', status: 'active' },
{ name: '武嘉怡', phone: '', studentNo: '', gender: '女', ethnicity: '汉族', organization: '26定向', supervisor: '', status: 'active' },
{ name: '刘禹含', phone: '', studentNo: '', gender: '女', ethnicity: '汉族', organization: '26定向', supervisor: '', status: 'active' },
{ name: '杜瑾慧', phone: '', studentNo: '', gender: '女', ethnicity: '汉族', organization: '26定向', supervisor: '', status: 'active' },
{ name: '孟凡志', phone: '', studentNo: '', gender: '男', ethnicity: '汉族', organization: '26定向', supervisor: '', status: 'active' },
];
const saved = await this.studentRepo.save(students);
@@ -394,7 +365,6 @@ export class SeedDevService {
for (const c of clsData) {
const saved = await this.classRepo.save({
...c,
departmentId: this.deptId,
startDate: '2026-04-01',
endDate: '2026-08-31',
maxStudents: 30,
@@ -481,7 +451,7 @@ export class SeedDevService {
{ studentIdx: 28, roomIdx: 18, checkInDate: '2026-06-04', notes: '' }, // 孟凡志 -> 3-107
];
const occupancies: Array<{ studentId: number; roomId: number; checkInDate: string; billingStartDate: string; rentalType: string; notes: string; departmentId: number; checkOutDate?: string; billingEndDate?: string }> = [];
const occupancies: Array<{ studentId: number; roomId: number; checkInDate: string; billingStartDate: string; rentalType: string; notes: string; checkOutDate?: string; billingEndDate?: string }> = [];
for (const m of mapping) {
const student = this.cachedStudents[m.studentIdx];
const room = this.cachedRooms[m.roomIdx];
@@ -493,7 +463,6 @@ export class SeedDevService {
billingStartDate: m.checkInDate,
rentalType: room.rentalCategory,
notes: m.notes,
departmentId: this.deptId,
});
}
}
@@ -510,7 +479,7 @@ export class SeedDevService {
private async seedSchedules(): Promise<void> {
const subjects = ['数学', '英语', '语文', '专业课', '政治', '历史'];
const schedules: Array<{ classId: number; classroomId: number; weekDay: number; startTime: string; endTime: string; startDate: string; endDate: string; subject: string; teacherId: number; scheduleType: string; departmentId: number }> = [];
const schedules: Array<{ classId: number; classroomId: number; weekDay: number; startTime: string; endTime: string; startDate: string; endDate: string; subject: string; teacherId: number; scheduleType: string }> = [];
for (const cls of this.cachedClasses.slice(0, 3)) {
for (let day = 1; day <= 5; day++) {
@@ -526,7 +495,6 @@ export class SeedDevService {
subject: pick(subjects),
teacherId: pick(this.cachedUsers).id,
scheduleType: ScheduleType.INTERNAL,
departmentId: this.deptId,
},
{
classId: cls.id,
@@ -539,7 +507,6 @@ export class SeedDevService {
subject: pick(subjects),
teacherId: pick(this.cachedUsers).id,
scheduleType: ScheduleType.INTERNAL,
departmentId: this.deptId,
},
);
}
@@ -562,7 +529,7 @@ export class SeedDevService {
{ roomIdx: 23, electric: { apr: 101.00, may: 98.18 }, water: { apr: 19.60, may: 19.60 } }, // 4-201
];
const expenses: Array<{ roomId: number; expenseType: string; amount: number; periodStart: string; periodEnd: string; description: string; departmentId: number }> = [];
const expenses: Array<{ roomId: number; expenseType: string; amount: number; periodStart: string; periodEnd: string; description: string }> = [];
for (const er of expenseRooms) {
const room = this.cachedRooms[er.roomIdx];
@@ -571,23 +538,23 @@ export class SeedDevService {
expenses.push({
roomId: room.id, expenseType: 'electricity', amount: er.electric.apr,
periodStart: '2026-04-01', periodEnd: '2026-04-30',
description: `电费 - ${room.roomNumber} 4月`, departmentId: this.deptId,
description: `电费 - ${room.roomNumber} 4月`,
});
expenses.push({
roomId: room.id, expenseType: 'water', amount: er.water.apr,
periodStart: '2026-04-01', periodEnd: '2026-04-30',
description: `水费 - ${room.roomNumber} 4月`, departmentId: this.deptId,
description: `水费 - ${room.roomNumber} 4月`,
});
// May
expenses.push({
roomId: room.id, expenseType: 'electricity', amount: er.electric.may,
periodStart: '2026-05-01', periodEnd: '2026-05-31',
description: `电费 - ${room.roomNumber} 5月`, departmentId: this.deptId,
description: `电费 - ${room.roomNumber} 5月`,
});
expenses.push({
roomId: room.id, expenseType: 'water', amount: er.water.may,
periodStart: '2026-05-01', periodEnd: '2026-05-31',
description: `水费 - ${room.roomNumber} 5月`, departmentId: this.deptId,
description: `水费 - ${room.roomNumber} 5月`,
});
}
@@ -600,7 +567,7 @@ export class SeedDevService {
private async seedDeposits(): Promise<void> {
// Real data: deposits tied to specific students
const depositStudents = [24, 25, 26, 27, 28, 19, 2]; // indices into cachedStudents
const deposits: Array<{ studentId: number; amount: number; status: string; paidDate: string; departmentId: number }> = [];
const deposits: Array<{ studentId: number; amount: number; status: string; paidDate: string }> = [];
for (const idx of depositStudents) {
const s = this.cachedStudents[idx];
@@ -610,13 +577,12 @@ export class SeedDevService {
amount: 500,
status: 'paid',
paidDate: s.id <= this.cachedStudents[25].id ? '2026-07-12' : '2026-06-04',
departmentId: this.deptId,
});
}
// Manual deposits with custom amounts
deposits.push({ studentId: this.cachedStudents[19].id, amount: 200, status: 'paid', paidDate: '2026-03-01', departmentId: this.deptId }); // 常智禹
deposits.push({ studentId: this.cachedStudents[2].id, amount: 121.60, status: 'paid', paidDate: '2026-03-01', departmentId: this.deptId }); // 陈昊天
deposits.push({ studentId: this.cachedStudents[19].id, amount: 200, status: 'paid', paidDate: '2026-03-01' }); // 常智禹
deposits.push({ studentId: this.cachedStudents[2].id, amount: 121.60, status: 'paid', paidDate: '2026-03-01' }); // 陈昊天
await this.depositRepo.save(deposits);
this.logger.log(`${deposits.length} deposits`);
@@ -649,7 +615,6 @@ export class SeedDevService {
personalAmount: 0,
totalAmount: bd.sharedApr,
status: 'paid',
departmentId: this.deptId,
});
await this.billItemRepo.save([
{ billId: billApr.id, roomId: room.id, expenseType: 'electricity', description: '电费分摊', days: 30, studentAmount: Math.round(bd.sharedApr * 0.82 * 100) / 100 },
@@ -665,7 +630,6 @@ export class SeedDevService {
personalAmount: 0,
totalAmount: bd.sharedMay,
status: 'paid',
departmentId: this.deptId,
});
await this.billItemRepo.save([
{ billId: billMay.id, roomId: room.id, expenseType: 'electricity', description: '电费分摊', days: 31, studentAmount: Math.round(bd.sharedMay * 0.82 * 100) / 100 },
@@ -680,7 +644,7 @@ export class SeedDevService {
private async seedAttendance(): Promise<void> {
const statuses = ['present', 'absent', 'late', 'leave'];
const records: Array<{ studentId: number; classId: number; attendanceDate: string; session: string; status: string; source: string; departmentId: number }> = [];
const records: Array<{ studentId: number; classId: number; attendanceDate: string; session: string; status: string; source: string }> = [];
for (const cls of this.cachedClasses) {
const classStudents = await this.classStudentRepo.find({ where: { classId: cls.id } });
@@ -700,7 +664,6 @@ export class SeedDevService {
session: 'am',
status: pick(statuses),
source: 'manual',
departmentId: this.deptId,
});
count++;
}
@@ -724,7 +687,6 @@ export class SeedDevService {
endDate: '2026-06-30',
totalAmount: 30000,
status: 'active',
departmentId: this.deptId,
});
this.logger.log(' ✓ 1 classroom rental');
}
@@ -741,7 +703,6 @@ export class SeedDevService {
subjectDirection: pick(['理科', '文科']),
grade: '高三',
campusLocation: '主校区',
departmentId: this.deptId,
}));
await this.profileRepo.save(profiles);
this.logger.log(`${profiles.length} student profiles`);
@@ -759,7 +720,6 @@ export class SeedDevService {
startDate: '2026-04-01',
endDate: '2026-08-31',
status: 'active',
departmentId: this.deptId,
}));
await this.enrollmentRepo.save(enrollments);
this.logger.log(`${enrollments.length} enrollments`);
@@ -770,7 +730,7 @@ export class SeedDevService {
private async seedExamScores(): Promise<void> {
const subjects = ['数学', '英语', '语文', '专业课'];
const exams = ['月考', '期中考试', '模拟考试'];
const scores: Array<{ studentId: number; examType: string; examName: string; subject: string; score: number; examDate: string; departmentId: number }> = [];
const scores: Array<{ studentId: number; examType: string; examName: string; subject: string; score: number; examDate: string }> = [];
for (const s of this.cachedStudents.slice(0, 10)) {
for (const exam of exams) {
@@ -782,7 +742,6 @@ export class SeedDevService {
subject: subj,
score: randInt(50, 100),
examDate: `2026-0${randInt(4, 6)}-${String(randInt(1, 28)).padStart(2, '0')}`,
departmentId: this.deptId,
});
}
}
@@ -802,7 +761,6 @@ export class SeedDevService {
content: `学习状态:${pick(['良好', '一般', '需加强'])}`,
followUpMethod: pick(['电话', '微信', '面谈']),
nextStep: '继续跟进',
departmentId: this.deptId,
}));
await this.learningRecordRepo.save(records);
this.logger.log(`${records.length} learning records`);

View File

@@ -6,13 +6,13 @@ import {
Bill, BillItem, User, Deposit,
Classroom, Tenant, ClassroomRental, Permission, Role,
Class, ClassStudent, ClassTeacher, ClassSchedule,
AttendanceRecord, Department, UserDepartment,
AttendanceRecord,
StudentProfile, StudentEnrollment, ExamScore, LearningRecord,
ExpenseType,
} from '../entities';
const SEED_ENTITIES = [
Department, User, Role, Permission, UserDepartment,
User, Role, Permission,
Tenant, Student, Room, Classroom, Occupancy,
RoomExpense, ExpenseType, Class,
ClassStudent, ClassTeacher, ClassSchedule, Bill, BillItem,

View File

@@ -44,8 +44,6 @@ export class CreateStudentDto {
@IsString()
supervisor?: string;
@IsOptional()
@IsInt()
departmentId?: number;
@IsOptional()
@IsInt()

View File

@@ -290,8 +290,7 @@ export class StudentsController {
}
}
}
const departmentId = req.headers?.['x-campus-id'] ? parseInt(String(req.headers['x-campus-id']), 10) || undefined : undefined;
const result = await this.service.batchImport(rows, departmentId);
const result = await this.service.batchImport(rows);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,

View File

@@ -39,11 +39,7 @@ export class StudentsService {
}
async create(dto: CreateStudentDto) {
const entity = this.repo.create(dto);
if (dto.departmentId) {
entity.departmentId = dto.departmentId;
}
return this.repo.save(entity);
return this.repo.save(this.repo.create(dto));
}
async update(id: number, dto: UpdateStudentDto) {
@@ -57,7 +53,6 @@ export class StudentsService {
if (student.status === 'archived') {
throw new BadRequestException('该学生已归档');
}
// 软删除:归档而非物理删除,保留历史数据
await this.repo.update(id, { status: 'archived' });
return { message: '已归档(数据已保留,可随时恢复)' };
}
@@ -110,7 +105,6 @@ export class StudentsService {
supervisor?: string;
tenantId?: number;
}[],
departmentId?: number,
) {
let imported = 0;
let skipped = 0;
@@ -136,7 +130,6 @@ export class StudentsService {
organization: row.organization || undefined,
supervisor: row.supervisor || undefined,
tenantId: row.tenantId || undefined,
departmentId: departmentId ?? undefined,
}),
);
imported++;
@@ -152,7 +145,6 @@ export class StudentsService {
const student = await this.repo.findOne({ where: { id: studentId } });
if (!student) throw new NotFoundException('学生不存在');
// Get all class enrollments for this student
const enrollments = await this.classStudentRepo.find({
where: { studentId },
relations: ['class'],
@@ -162,13 +154,11 @@ export class StudentsService {
return { student, enrollments: [] };
}
// For each enrollment, compute attendance stats
const classIds = enrollments.map((e) => e.classId);
const attendanceRecords = await this.attendanceRepo.find({
where: { studentId, classId: In(classIds) },
});
// Group attendance by class
const attendanceByClass = new Map<number, AttendanceRecord[]>();
for (const r of attendanceRecords) {
const list = attendanceByClass.get(r.classId) || [];

View File

@@ -3,10 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Not, IsNull } from 'typeorm';
import {
ClassSchedule,
ClassStudent,
ClassTeacher,
Department,
UserDepartment,
} from '../entities';
import { DingTalkService, DingTalkScheduleItem } from '../integration/dingtalk.service';
@@ -48,10 +45,6 @@ export class ScheduleSyncService {
private readonly scheduleRepo: Repository<ClassSchedule>,
@InjectRepository(ClassTeacher)
private readonly classTeacherRepo: Repository<ClassTeacher>,
@InjectRepository(Department)
private readonly deptRepo: Repository<Department>,
@InjectRepository(UserDepartment)
private readonly userDeptRepo: Repository<UserDepartment>,
private readonly dingTalkService: DingTalkService,
) {}
@@ -75,7 +68,6 @@ export class ScheduleSyncService {
status: 'active',
teacherId: Not(IsNull()),
},
relations: ['department'],
});
if (schedules.length === 0) {
@@ -110,102 +102,84 @@ export class ScheduleSyncService {
}
}
// ── Step 4: 按部门分组 ──
const deptGroups = new Map<
number,
{ deptName: string; schedules: ClassSchedule[]; teacherIds: Set<number> }
>();
// ── Step 4: Collect teacher IDs (no department entity) ──
const teacherIds = new Set<number>();
for (const s of schedules) {
const deptId = s.departmentId || 0;
if (!deptGroups.has(deptId)) {
deptGroups.set(deptId, {
deptName: (s.department as Department)?.name || `部门${deptId}`,
schedules: [],
teacherIds: new Set(),
});
}
const group = deptGroups.get(deptId)!;
group.schedules.push(s);
if (s.teacherId) group.teacherIds.add(s.teacherId);
if (s.teacherId) teacherIds.add(s.teacherId);
}
// ── Step 5: 每个部门 → 考勤组 → 排班 ──
// ── Step 5: Single group → attendance group → scheduling ──
let syncedItems = 0;
let skippedNoMapping = 0;
let groupCount = 0;
const groupDetails: ScheduleSyncResult['groups'] = [];
for (const [deptId, group] of deptGroups) {
// 获取该部门教师的钉钉 userIds
const dingUserIds: string[] = [];
const teacherDingMap = new Map<number, string>(); // local teacherId → dingUserId
for (const tid of group.teacherIds) {
const dingId = userIdToDingId.get(tid);
if (dingId) {
dingUserIds.push(dingId);
teacherDingMap.set(tid, dingId);
}
// Collect teacher→ding mapping
const dingUserIds: string[] = [];
const teacherDingMap = new Map<number, string>();
for (const tid of teacherIds) {
const dingId = userIdToDingId.get(tid);
if (dingId) {
dingUserIds.push(dingId);
teacherDingMap.set(tid, dingId);
}
if (dingUserIds.length === 0) {
skippedNoMapping += group.schedules.length;
this.logger.warn(`部门 ${group.deptName}: 无钉钉用户映射,跳过`);
continue;
}
// 该部门使用的班次 IDs
const deptShiftIds = new Set<number>();
for (const s of group.schedules) {
const key = shiftKey(s.startTime, s.endTime);
const sid = timeToShiftId.get(key);
if (sid) deptShiftIds.add(sid);
}
// 创建/匹配考勤组
const groupName = `排课_${group.deptName}`;
let attendanceGroupId: number;
try {
attendanceGroupId = await this.dingTalkService.findOrCreateAttendanceGroup(
groupName,
opUserId,
dingUserIds,
[...deptShiftIds],
);
groupCount++;
} catch (e) {
this.logger.error(`创建考勤组 ${groupName} 失败: ${(e as Error).message}`);
continue;
}
// 展开排课为每日排班
const items = this.expandSchedules(
group.schedules,
teacherDingMap,
timeToShiftId,
startDate,
endDate,
);
skippedNoMapping += group.schedules.length - new Set(items.map((i) => i.userid)).size;
// 分批写入每次最多200条
for (let i = 0; i < items.length; i += 200) {
const batch = items.slice(i, i + 200);
try {
await this.dingTalkService.scheduleUsers(attendanceGroupId, batch, opUserId);
syncedItems += batch.length;
} catch (e) {
this.logger.error(`排班写入失败 (groupId=${attendanceGroupId}, offset=${i}): ${(e as Error).message}`);
}
}
groupDetails.push({
deptName: group.deptName,
groupId: attendanceGroupId,
itemCount: items.length,
});
}
if (dingUserIds.length === 0) {
this.logger.warn('无钉钉用户映射,跳过全部排班');
return { scheduleCount: schedules.length, shiftCount, groupCount: 0, syncedItems: 0, skippedNoMapping: schedules.length, groups: [] };
}
// All used shift IDs
const allShiftIds = new Set<number>();
for (const s of schedules) {
const key = shiftKey(s.startTime, s.endTime);
const sid = timeToShiftId.get(key);
if (sid) allShiftIds.add(sid);
}
// Create/find attendance group
const groupName = '排课_全部';
let attendanceGroupId: number;
try {
attendanceGroupId = await this.dingTalkService.findOrCreateAttendanceGroup(
groupName,
opUserId,
dingUserIds,
[...allShiftIds],
);
groupCount++;
} catch (e) {
this.logger.error(`创建考勤组 ${groupName} 失败: ${(e as Error).message}`);
return { scheduleCount: schedules.length, shiftCount, groupCount: 0, syncedItems: 0, skippedNoMapping: schedules.length, groups: [] };
}
// Expand schedules to daily items
const items = this.expandSchedules(
schedules,
teacherDingMap,
timeToShiftId,
startDate,
endDate,
);
skippedNoMapping = schedules.length - new Set(items.map((i) => i.userid)).size;
// Batch write (max 200 per batch)
for (let i = 0; i < items.length; i += 200) {
const batch = items.slice(i, i + 200);
try {
await this.dingTalkService.scheduleUsers(attendanceGroupId, batch, opUserId);
syncedItems += batch.length;
} catch (e) {
this.logger.error(`排班写入失败 (groupId=${attendanceGroupId}, offset=${i}): ${(e as Error).message}`);
}
}
groupDetails.push({
deptName: groupName,
groupId: attendanceGroupId,
itemCount: items.length,
});
this.logger.log(
`排班同步完成: ${schedules.length} 条排课 → ${syncedItems} 条钉钉排班, ` +
`${shiftCount} 班次, ${groupCount} 考勤组, 跳过 ${skippedNoMapping} 条无映射`,

View File

@@ -7,8 +7,6 @@ import {
SyncState,
StudentDingMapping,
ClassSchedule,
Department,
UserDepartment,
ClassTeacher,
User,
Student,
@@ -26,8 +24,6 @@ import { ScheduleSyncService } from './schedule-sync.service';
SyncState,
StudentDingMapping,
ClassSchedule,
Department,
UserDepartment,
ClassTeacher,
User,
Student,

View File

@@ -214,6 +214,6 @@ export class SyncService {
private async performWeComSync(_lastSyncAt: Date | null): Promise<number> {
const result = await this.weComService.syncAll();
return result.deptCount + result.userCount;
return result.userCount;
}
}