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,10 +0,0 @@
<!-- CODEGRAPH_START -->
## CodeGraph
In repositories indexed by CodeGraph (a `.codegraph/` directory exists at the repo root), reach for it BEFORE grep/find or reading files when you need to understand or locate code:
- **MCP tool** (when available): `codegraph_explore` answers most code questions in one call — the relevant symbols' verbatim source plus the call paths between them, including dynamic-dispatch hops grep can't follow. Name a file or symbol in the query to read its current line-numbered source. If it's listed but deferred, load it by name via tool search.
- **Shell** (always works): `codegraph explore "<symbol names or question>"` prints the same output.
If there is no `.codegraph/` directory, skip CodeGraph entirely — indexing is the user's decision.
<!-- CODEGRAPH_END -->

View File

@@ -1,19 +0,0 @@
{
"permissions": {
"allow": [
"mcp__codegraph__*"
]
},
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "codegraph prompt-hook"
}
]
}
]
}
}

3
.gitignore vendored
View File

@@ -51,3 +51,6 @@ apps/admin/node_modules/
apps/admin/dist/ apps/admin/dist/
.codegraph/ .codegraph/
.worktrees/ .worktrees/
.claude/
.omp/
.superpowers/

View File

@@ -1,47 +0,0 @@
# 恭学教育学生管理系统 — 项目约束
完整 PRD 文档:`../../Downloads/PRD-恭学教育学生管理系统.md`(相对项目根目录)
## 技术栈
| 层级 | 技术 |
|------|------|
| 架构 | Monorepo (Turborepo) |
| 前端 | React 19 + Vite + Ant Design 6 + ECharts |
| 后端 | NestJS 11 + TypeORM 0.3 + JWT + Passport |
| 数据库 | 开发 SQLite / 生产 MySQL 8 |
| 部署 | Docker Compose (MySQL + NestJS + Nginx) |
## 核心用户角色
- 超级管理员:系统配置、账号管理、全部数据
- 教职工(宿管+财务):宿舍管理、费用录入、账单生成、指定部门
- 班主任:本班学生信息、档案、费用、考勤
- 学生:仅本人数据
## 业务域
- 学员管理域:学生管理、学生档案、考勤管理
- 教务管理域:班级管理(新)、排课管理(新)、教师管理、教室管理
- 住宿管理域:宿舍管理、入住管理
- 财务运营域:费用管理、账单管理、押金管理、租赁方管理、数据面板
## 横切关注点
- RBAC 权限控制
- 操作日志(所有关键写操作)
- 钉钉/企业微信集成
- 报表导出、批量导入
## 上线优先级
**P0阻塞上线**:班级管理、排课管理、宿舍管理增强、账单管理(长租)、操作日志
**P1上线前完成**考勤管理前端、数据面板、RBAC 扩展、教室管理增强、organization→tenant_id
**P2可迭代**:报表导出、教师档案增强、多班型对比、押金分期、第三方集成
## 编码规范
- 所有涉及大量数据的列表/表单页面必须有详细的筛选方案
- 敏感信息(手机号、身份证)需脱敏展示,查看时二次确认+记录日志
- 遵循现有 NestJS 模块结构:每个业务模块独立目录,含 entity/dto/service/controller
- 前端页面放在 `apps/admin/src/pages/` 下,每个模块独立目录

View File

@@ -1,14 +0,0 @@
## Task 7 — Cleanup test files
### Sync service spec
- `apps/server/src/sync/sync.service.spec.ts` was already deleted in commit `7b86918` (Task 4: remove importDingTalkUsers). No action needed.
### Attendance service spec
- `apps/server/src/attendance/attendance.service.spec.ts` already uses `StudentDingMapping` (lines 12, 42). No `UserDingMapping` references remain. Updated in commit `ef1b46b`.
### Verification
- `npx tsc --noEmit` in apps/server: 277 diagnostics, all pre-existing jest type errors (`@types/jest` not installed). Zero errors related to `UserDingMapping` or `StudentDingMapping`.
- No files to commit — both targets are already clean.
### Conclusion
Task complete with zero changes needed. Previous tasks (Task 4 entity rename, Task 2 import swap) already handled these test updates.

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

View File

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

View File

@@ -43,10 +43,6 @@ export class AttendanceService {
throw new BadRequestException('records array must not be empty'); 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 entities = dto.records.map((r) => {
const entity = this.attendanceRepo.create({ const entity = this.attendanceRepo.create({
@@ -58,7 +54,6 @@ export class AttendanceService {
remark: r.remark, remark: r.remark,
source: r.source || 'manual', source: r.source || 'manual',
}); });
entity.departmentId = deptMap.get(r.studentId)!;
return entity; return entity;
}); });
@@ -128,7 +123,6 @@ export class AttendanceService {
status: 'pending', status: 'pending',
source: 'schedule', source: 'schedule',
}); });
entity.departmentId = cs.student?.departmentId ?? undefined;
entities.push(entity); entities.push(entity);
existingKeys.add(key); existingKeys.add(key);
} }

View File

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

View File

@@ -9,7 +9,7 @@ import { Occupancy } from '../entities/occupancy.entity';
import { Room } from '../entities/room.entity'; import { Room } from '../entities/room.entity';
import { Deposit } from '../entities/deposit.entity'; import { Deposit } from '../entities/deposit.entity';
import { GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto'; import { GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto';
import { CampusScope } from '../common/campus-scope';
@Injectable() @Injectable()
export class BillsService { export class BillsService {
@@ -22,7 +22,6 @@ export class BillsService {
@InjectRepository(Room) private roomRepo: Repository<Room>, @InjectRepository(Room) private roomRepo: Repository<Room>,
@InjectRepository(Deposit) private depositRepo: Repository<Deposit>, @InjectRepository(Deposit) private depositRepo: Repository<Deposit>,
private dataSource: DataSource, private dataSource: DataSource,
private readonly scope: CampusScope,
) {} ) {}
/** /**
@@ -181,15 +180,6 @@ export class BillsService {
// 合并所有涉及的学生 // 合并所有涉及的学生
const allStudentIds = new Set([...studentBillData.keys(), ...personalMap.keys()]); 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[] = []; const bills: Bill[] = [];
for (const studentId of allStudentIds) { for (const studentId of allStudentIds) {
const shared = studentBillData.get(studentId)?.shared || 0; const shared = studentBillData.get(studentId)?.shared || 0;
@@ -204,7 +194,6 @@ export class BillsService {
personalAmount: personal, personalAmount: personal,
totalAmount: total, totalAmount: total,
status: 'draft', status: 'draft',
departmentId: studentDeptMap.get(studentId),
}); });
const savedBill = await this.billRepo.save(bill); const savedBill = await this.billRepo.save(bill);
@@ -229,12 +218,10 @@ export class BillsService {
status?: string; status?: string;
expenseType?: string; expenseType?: string;
}) { }) {
const scopeIds = await this.scope.getScopeDepartmentIds();
const qb = this.billRepo const qb = this.billRepo
.createQueryBuilder('b') .createQueryBuilder('b')
.leftJoinAndSelect('b.student', 'student') .leftJoinAndSelect('b.student', 'student')
.orderBy('b.generatedAt', 'DESC'); .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?.periodStart) qb.andWhere('b.periodStart = :ps', { ps: query.periodStart });
if (query?.periodEnd) qb.andWhere('b.periodEnd = :pe', { pe: query.periodEnd }); if (query?.periodEnd) qb.andWhere('b.periodEnd = :pe', { pe: query.periodEnd });
if (query?.studentId) qb.andWhere('b.studentId = :sid', { sid: query.studentId }); if (query?.studentId) qb.andWhere('b.studentId = :sid', { sid: query.studentId });

View File

@@ -1,14 +1,14 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { CommonModule } from '../common/common.module'; 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 { ClassesService } from './classes.service';
import { ClassesController } from './classes.controller'; import { ClassesController } from './classes.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module'; import { OperationLogsModule } from '../operation-logs/operation-logs.module';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
@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], controllers: [ClassesController],
providers: [ClassesService], providers: [ClassesService],
exports: [ClassesService], exports: [ClassesService],

View File

@@ -147,7 +147,6 @@ export class ClassesService {
this.studentRepo.create({ this.studentRepo.create({
name: `dd_${dingUserId}`, name: `dd_${dingUserId}`,
status: 'active', status: 'active',
departmentId: undefined,
}) })
); );
const savedStudents = await this.studentRepo.save(newStudents); 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 { Classroom } from '../entities/classroom.entity';
import { Tenant } from '../entities/tenant.entity'; import { Tenant } from '../entities/tenant.entity';
import { ClassSchedule } from '../entities/class-schedule.entity'; import { ClassSchedule } from '../entities/class-schedule.entity';
import { CampusScope } from '../common/campus-scope';
import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto'; import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto';
function mockQueryBuilder<T>(results: T[] = []) { function mockQueryBuilder<T>(results: T[] = []) {
@@ -33,7 +32,6 @@ describe('ClassroomRentalsService — findConflicts', () => {
{ provide: getRepositoryToken(Classroom), useValue: {} }, { provide: getRepositoryToken(Classroom), useValue: {} },
{ provide: getRepositoryToken(Tenant), useValue: {} }, { provide: getRepositoryToken(Tenant), useValue: {} },
{ provide: getRepositoryToken(ClassSchedule), useValue: { createQueryBuilder: jest.fn() } }, { provide: getRepositoryToken(ClassSchedule), useValue: { createQueryBuilder: jest.fn() } },
{ provide: CampusScope, useValue: { getScopeDepartmentIds: jest.fn().mockResolvedValue(null) } },
], ],
}).compile(); }).compile();
@@ -134,7 +132,6 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
{ provide: getRepositoryToken(Classroom), useValue: classroomRepo }, { provide: getRepositoryToken(Classroom), useValue: classroomRepo },
{ provide: getRepositoryToken(Tenant), useValue: tenantRepo }, { provide: getRepositoryToken(Tenant), useValue: tenantRepo },
{ provide: getRepositoryToken(ClassSchedule), useValue: scheduleRepo }, { provide: getRepositoryToken(ClassSchedule), useValue: scheduleRepo },
{ provide: CampusScope, useValue: { getScopeDepartmentIds: jest.fn().mockResolvedValue(null) } },
], ],
}).compile(); }).compile();

View File

@@ -11,7 +11,7 @@ import { Classroom } from '../entities/classroom.entity';
import { Tenant } from '../entities/tenant.entity'; import { Tenant } from '../entities/tenant.entity';
import { ClassSchedule } from '../entities/class-schedule.entity'; import { ClassSchedule } from '../entities/class-schedule.entity';
import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto'; import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto';
import { CampusScope } from '../common/campus-scope';
import * as path from 'path'; import * as path from 'path';
import * as fs from 'fs'; import * as fs from 'fs';
@@ -37,7 +37,6 @@ export class ClassroomRentalsService {
@InjectRepository(Classroom) private classroomRepo: Repository<Classroom>, @InjectRepository(Classroom) private classroomRepo: Repository<Classroom>,
@InjectRepository(Tenant) private tenantRepo: Repository<Tenant>, @InjectRepository(Tenant) private tenantRepo: Repository<Tenant>,
@InjectRepository(ClassSchedule) private scheduleRepo: Repository<ClassSchedule>, @InjectRepository(ClassSchedule) private scheduleRepo: Repository<ClassSchedule>,
private readonly scope: CampusScope,
) {} ) {}
get uploadDir(): string { get uploadDir(): string {
@@ -57,13 +56,11 @@ export class ClassroomRentalsService {
month?: string; month?: string;
includeEnded?: boolean; includeEnded?: boolean;
}) { }) {
const scopeIds = await this.scope.getScopeDepartmentIds();
const qb = this.repo const qb = this.repo
.createQueryBuilder('r') .createQueryBuilder('r')
.leftJoinAndSelect('r.classroom', 'classroom') .leftJoinAndSelect('r.classroom', 'classroom')
.leftJoinAndSelect('r.tenant', 'tenant') .leftJoinAndSelect('r.tenant', 'tenant')
.orderBy('r.startDate', 'DESC'); .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?.classroomId) qb.andWhere('r.classroomId = :cid', { cid: query.classroomId });
if (query?.tenantId) qb.andWhere('r.tenantId = :tid', { tid: query.tenantId }); if (query?.tenantId) qb.andWhere('r.tenantId = :tid', { tid: query.tenantId });
if (query?.month) { if (query?.month) {
@@ -142,9 +139,7 @@ export class ClassroomRentalsService {
}); });
} }
const rental = this.repo.create({ ...dto, createdBy: userId, status: 'active' }); const rental = this.repo.create({ ...dto, createdBy: userId, status: 'active' });
rental.departmentId = classroom.departmentId;
const saved = await this.repo.save(rental); const saved = await this.repo.save(rental);
await this.syncScheduleFromRental(saved, tenant.name);
return saved; return saved;
} }
@@ -221,7 +216,6 @@ export class ClassroomRentalsService {
rentalId: rental.id, rentalId: rental.id,
status: 'active', status: 'active',
notes: rental.notes, notes: rental.notes,
departmentId: rental.departmentId,
}; };
if (schedule) { if (schedule) {
await this.scheduleRepo.update(schedule.id, data); await this.scheduleRepo.update(schedule.id, data);

View File

@@ -211,8 +211,7 @@ export class ClassroomsController {
supervisor: String(row.getCell(7).value || '') || undefined, 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);
const result = await this.service.batchImport(rows, departmentId);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,

View File

@@ -4,7 +4,6 @@ import { Repository, Not } from 'typeorm';
import { Classroom } from '../entities/classroom.entity'; import { Classroom } from '../entities/classroom.entity';
import { ClassroomRental } from '../entities/classroom-rental.entity'; import { ClassroomRental } from '../entities/classroom-rental.entity';
import { ClassSchedule } from '../entities/class-schedule.entity'; import { ClassSchedule } from '../entities/class-schedule.entity';
import { CampusScope } from '../common/campus-scope';
import { CreateClassroomDto, UpdateClassroomDto } from './dto/classroom.dto'; import { CreateClassroomDto, UpdateClassroomDto } from './dto/classroom.dto';
@Injectable() @Injectable()
@@ -14,7 +13,6 @@ export class ClassroomsService {
@InjectRepository(Classroom) private repo: Repository<Classroom>, @InjectRepository(Classroom) private repo: Repository<Classroom>,
@InjectRepository(ClassroomRental) private rentalRepo: Repository<ClassroomRental>, @InjectRepository(ClassroomRental) private rentalRepo: Repository<ClassroomRental>,
@InjectRepository(ClassSchedule) private scheduleRepo: Repository<ClassSchedule>, @InjectRepository(ClassSchedule) private scheduleRepo: Repository<ClassSchedule>,
private readonly scope: CampusScope,
) {} ) {}
async findAll(query?: { building?: string; roomType?: string; includeArchived?: boolean }) { 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?.building) where.building = query.building;
if (query?.roomType) where.roomType = query.roomType; if (query?.roomType) where.roomType = query.roomType;
if (!query?.includeArchived) where.status = Not('archived'); 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)); const usageMap = await this.getCurrentUsageForClassrooms(list.map((c) => c.id));
return list.map((c) => ({ ...c, currentUsage: usageMap.get(c.id) ?? null })); return list.map((c) => ({ ...c, currentUsage: usageMap.get(c.id) ?? null }));
} }
@@ -37,9 +35,7 @@ export class ClassroomsService {
async create(dto: CreateClassroomDto) { async create(dto: CreateClassroomDto) {
const exists = await this.repo.findOne({ where: { name: dto.name } }); const exists = await this.repo.findOne({ where: { name: dto.name } });
if (exists) throw new BadRequestException(`教室 ${dto.name} 已存在`); if (exists) throw new BadRequestException(`教室 ${dto.name} 已存在`);
const entity = this.repo.create(dto); return this.repo.save(this.repo.create(dto));
if (dto.departmentId) entity.departmentId = dto.departmentId;
return this.repo.save(entity);
} }
async update(id: number, dto: UpdateClassroomDto) { async update(id: number, dto: UpdateClassroomDto) {
@@ -136,7 +132,6 @@ export class ClassroomsService {
roomType?: string; roomType?: string;
courseType?: string; courseType?: string;
}[], }[],
departmentId?: number,
) { ) {
let imported = 0; let imported = 0;
let skipped = 0; let skipped = 0;
@@ -145,7 +140,7 @@ export class ClassroomsService {
if (!row.name?.trim()) { skipped++; continue; } if (!row.name?.trim()) { skipped++; continue; }
const exists = await this.repo.findOne({ where: { name: row.name.trim() } }); const exists = await this.repo.findOne({ where: { name: row.name.trim() } });
if (exists) { errors.push(`教室 ${row.name} 已存在`); skipped++; continue; } 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++; imported++;
} }
return { message: `成功导入 ${imported} 间教室,跳过 ${skipped}`, imported, skipped, errors: errors.length > 0 ? errors : undefined }; return { message: `成功导入 ${imported} 间教室,跳过 ${skipped}`, imported, skipped, errors: errors.length > 0 ? errors : undefined };

View File

@@ -33,9 +33,6 @@ export class CreateClassroomDto {
@IsString() @IsString()
notes?: string; notes?: string;
@IsOptional()
@IsInt()
departmentId?: number;
} }
export class UpdateClassroomDto { 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 { Module } from '@nestjs/common';
import { CampusScope } from './campus-scope';
import { CampusScopeMiddleware } from './campus-scope.middleware';
import { DepartmentsModule } from '../departments/departments.module';
@Module({ @Module({})
imports: [DepartmentsModule],
providers: [CampusScope, CampusScopeMiddleware],
exports: [CampusScope, CampusScopeMiddleware, DepartmentsModule],
})
export class CommonModule {} export class CommonModule {}

View File

@@ -13,7 +13,7 @@ import { Class } from '../entities/class.entity';
import { Deposit } from '../entities/deposit.entity'; import { Deposit } from '../entities/deposit.entity';
import { ClassroomRental } from '../entities/classroom-rental.entity'; import { ClassroomRental } from '../entities/classroom-rental.entity';
import { ClassTeacher } from '../entities/class-teacher.entity'; import { ClassTeacher } from '../entities/class-teacher.entity';
import { CampusScope } from '../common/campus-scope';
@Injectable() @Injectable()
export class DashboardService { export class DashboardService {
@@ -31,23 +31,20 @@ export class DashboardService {
@InjectRepository(Deposit) private depositRepo: Repository<Deposit>, @InjectRepository(Deposit) private depositRepo: Repository<Deposit>,
@InjectRepository(ClassroomRental) private rentalRepo: Repository<ClassroomRental>, @InjectRepository(ClassroomRental) private rentalRepo: Repository<ClassroomRental>,
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>, @InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
private readonly scope: CampusScope,
) {} ) {}
async getStats() { async getStats() {
const today = new Date(); const today = new Date();
const todayStr = today.toISOString().slice(0, 10); const todayStr = today.toISOString().slice(0, 10);
const currentMonth = todayStr.slice(0, 7); // YYYY-MM 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 totalRooms = await this.roomRepo.count({ where: { status: Not('archived') } });
const totalStudents = await this.studentRepo.count({ where: await this.scope.filter({ status: 'active' }) }); const totalStudents = await this.studentRepo.count({ where: { status: 'active' } });
const occupiedBeds = await this.occRepo.count({ where: await this.scope.filter({ checkOutDate: IsNull() }) }); const occupiedBeds = await this.occRepo.count({ where: { checkOutDate: IsNull() } });
const capQb = this.roomRepo const capQb = this.roomRepo
.createQueryBuilder('r') .createQueryBuilder('r')
.select('SUM(r.capacity)', 'total') .select('SUM(r.capacity)', 'total')
.where('r.status != :archived', { archived: 'archived' }); .where('r.status != :archived', { archived: 'archived' });
if (scopeIds) capQb.andWhere('r.departmentId IN (:...scopeIds)', { scopeIds });
const totalCapacity = await capQb.getRawOne(); const totalCapacity = await capQb.getRawOne();
const cap = totalCapacity?.total || 0; const cap = totalCapacity?.total || 0;
const occupancyRate = cap > 0 ? ((occupiedBeds / cap) * 100).toFixed(1) : 0; const occupancyRate = cap > 0 ? ((occupiedBeds / cap) * 100).toFixed(1) : 0;
@@ -58,11 +55,10 @@ export class DashboardService {
.addSelect('COUNT(*)', 'count') .addSelect('COUNT(*)', 'count')
.addSelect('SUM(b.totalAmount)', 'total') .addSelect('SUM(b.totalAmount)', 'total')
.groupBy('b.status'); .groupBy('b.status');
if (scopeIds) billStatsQb.andWhere('b.departmentId IN (:...scopeIds)', { scopeIds });
const billStats = await billStatsQb.getRawMany(); const billStats = await billStatsQb.getRawMany();
// New fields // New fields
const classroomCount = await this.classroomRepo.count({ where: await this.scope.filter({}) }); const classroomCount = await this.classroomRepo.count({ where: {} });
const occQb = this.scheduleRepo const occQb = this.scheduleRepo
.createQueryBuilder('s') .createQueryBuilder('s')
@@ -70,7 +66,6 @@ export class DashboardService {
.where('s.status = :active', { active: 'active' }) .where('s.status = :active', { active: 'active' })
.andWhere('s.startDate <= :today', { today: todayStr }) .andWhere('s.startDate <= :today', { today: todayStr })
.andWhere('s.endDate >= :today', { today: todayStr }); .andWhere('s.endDate >= :today', { today: todayStr });
if (scopeIds) occQb.andWhere('s.departmentId IN (:...scopeIds)', { scopeIds });
const occResult = await occQb.getRawOne(); const occResult = await occQb.getRawOne();
const occupiedClassrooms = parseInt(occResult?.cnt || '0', 10); const occupiedClassrooms = parseInt(occResult?.cnt || '0', 10);
const classroomOccupancyRate = classroomCount > 0 const classroomOccupancyRate = classroomCount > 0
@@ -83,7 +78,6 @@ export class DashboardService {
.addSelect('COUNT(*)', 'count') .addSelect('COUNT(*)', 'count')
.where('a.attendanceDate = :today', { today: todayStr }) .where('a.attendanceDate = :today', { today: todayStr })
.groupBy('a.status'); .groupBy('a.status');
if (scopeIds) attTodayQb.andWhere('a.departmentId IN (:...scopeIds)', { scopeIds });
const attTodayStats = await attTodayQb.getRawMany(); const attTodayStats = await attTodayQb.getRawMany();
const todayTotal = attTodayStats.reduce((sum, r) => sum + parseInt(r.count, 10), 0); const todayTotal = attTodayStats.reduce((sum, r) => sum + parseInt(r.count, 10), 0);
const todayPresent = attTodayStats const todayPresent = attTodayStats
@@ -99,7 +93,6 @@ export class DashboardService {
.where('b.status = :paid', { paid: 'paid' }) .where('b.status = :paid', { paid: 'paid' })
.andWhere('b.periodStart >= :start', { start: `${currentMonth}-01` }) .andWhere('b.periodStart >= :start', { start: `${currentMonth}-01` })
.andWhere('b.periodStart < :end', { end: this.nextMonth(currentMonth) }); .andWhere('b.periodStart < :end', { end: this.nextMonth(currentMonth) });
if (scopeIds) incomeQb.andWhere('b.departmentId IN (:...scopeIds)', { scopeIds });
const incomeResult = await incomeQb.getRawOne(); const incomeResult = await incomeQb.getRawOne();
const monthlyIncome = parseFloat(incomeResult?.total || '0'); const monthlyIncome = parseFloat(incomeResult?.total || '0');
@@ -107,9 +100,8 @@ export class DashboardService {
const incomeTrend = await this.getIncomeTrend(currentMonth); const incomeTrend = await this.getIncomeTrend(currentMonth);
// --- New stats --- // --- 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 const teacherResult = await this.classTeacherRepo
.createQueryBuilder('ct') .createQueryBuilder('ct')
.select('COUNT(DISTINCT ct.userId)', 'cnt') .select('COUNT(DISTINCT ct.userId)', 'cnt')
@@ -121,11 +113,10 @@ export class DashboardService {
.select('SUM(d.amount)', 'total') .select('SUM(d.amount)', 'total')
.where('d.status = :paid', { paid: 'paid' }) .where('d.status = :paid', { paid: 'paid' })
.andWhere('d.refundStatus IS NULL'); .andWhere('d.refundStatus IS NULL');
if (scopeIds) pendingQb.andWhere('d.departmentId IN (:...scopeIds)', { scopeIds });
const pendingResult = await pendingQb.getRawOne(); const pendingResult = await pendingQb.getRawOne();
const pendingDeposits = parseFloat(pendingResult?.total || '0'); 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 const occByBldQb = this.occRepo
.createQueryBuilder('o') .createQueryBuilder('o')
@@ -133,7 +124,6 @@ export class DashboardService {
.select('r.building', 'building') .select('r.building', 'building')
.addSelect('COUNT(*)', 'count') .addSelect('COUNT(*)', 'count')
.where('o.checkOutDate IS NULL'); .where('o.checkOutDate IS NULL');
if (scopeIds) occByBldQb.andWhere('r.departmentId IN (:...scopeIds)', { scopeIds });
const occupancyByBuilding = await occByBldQb.groupBy('r.building').getRawMany(); const occupancyByBuilding = await occByBldQb.groupBy('r.building').getRawMany();
const attendanceByStatus = attTodayStats.reduce((acc, r) => { const attendanceByStatus = attTodayStats.reduce((acc, r) => {
@@ -147,7 +137,6 @@ export class DashboardService {
.addSelect('SUM(e.amount)', 'total') .addSelect('SUM(e.amount)', 'total')
.where('e.periodStart >= :start', { start: `${currentMonth}-01` }) .where('e.periodStart >= :start', { start: `${currentMonth}-01` })
.andWhere('e.periodEnd <= :end', { end: this.nextMonth(currentMonth) }); .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(); const expenseByType = await expByTypeQb.groupBy('e.expenseType').getRawMany();
return { return {
@@ -238,7 +227,6 @@ export class DashboardService {
// 甘特图数据:每个宿舍的入住时间线 // 甘特图数据:每个宿舍的入住时间线
async getGanttData(query?: { periodStart?: string; periodEnd?: string; building?: string }) { async getGanttData(query?: { periodStart?: string; periodEnd?: string; building?: string }) {
const scopeIds = await this.scope.getScopeDepartmentIds();
const qb = this.occRepo const qb = this.occRepo
.createQueryBuilder('o') .createQueryBuilder('o')
.leftJoinAndSelect('o.student', 'student') .leftJoinAndSelect('o.student', 'student')
@@ -247,7 +235,6 @@ export class DashboardService {
.orderBy('room.roomNumber', 'ASC') .orderBy('room.roomNumber', 'ASC')
.addOrderBy('o.checkInDate', 'ASC'); .addOrderBy('o.checkInDate', 'ASC');
if (scopeIds) qb.andWhere('room.departmentId IN (:...scopeIds)', { scopeIds });
if (query?.building) { if (query?.building) {
qb.andWhere('room.building = :building', { building: query.building }); qb.andWhere('room.building = :building', { building: query.building });
@@ -283,13 +270,11 @@ export class DashboardService {
} }
// 费用统计 // 费用统计
async getExpenseStats(periodStart?: string, periodEnd?: string) { async getExpenseStats(periodStart?: string, periodEnd?: string) {
const scopeIds = await this.scope.getScopeDepartmentIds();
const qb = this.expRepo const qb = this.expRepo
.createQueryBuilder('e') .createQueryBuilder('e')
.select('e.expenseType', 'type') .select('e.expenseType', 'type')
.addSelect('SUM(e.amount)', 'total') .addSelect('SUM(e.amount)', 'total')
.groupBy('e.expenseType'); .groupBy('e.expenseType');
if (scopeIds) qb.andWhere('e.departmentId IN (:...scopeIds)', { scopeIds });
if (periodStart) qb.andWhere('e.periodStart >= :ps', { ps: periodStart }); if (periodStart) qb.andWhere('e.periodStart >= :ps', { ps: periodStart });
if (periodEnd) qb.andWhere('e.periodEnd <= :pe', { pe: periodEnd }); if (periodEnd) qb.andWhere('e.periodEnd <= :pe', { pe: periodEnd });
return qb.getRawMany(); return qb.getRawMany();
@@ -297,7 +282,6 @@ export class DashboardService {
// 各宿舍费用排行 // 各宿舍费用排行
async getRoomExpenseRanking(periodStart?: string, periodEnd?: string) { async getRoomExpenseRanking(periodStart?: string, periodEnd?: string) {
const scopeIds = await this.scope.getScopeDepartmentIds();
const qb = this.expRepo const qb = this.expRepo
.createQueryBuilder('e') .createQueryBuilder('e')
.leftJoin('e.room', 'room') .leftJoin('e.room', 'room')
@@ -307,7 +291,6 @@ export class DashboardService {
.groupBy('e.roomId') .groupBy('e.roomId')
.orderBy('total', 'DESC') .orderBy('total', 'DESC')
.limit(20); .limit(20);
if (scopeIds) qb.andWhere('e.departmentId IN (:...scopeIds)', { scopeIds });
if (periodStart) qb.andWhere('e.periodStart >= :ps', { ps: periodStart }); if (periodStart) qb.andWhere('e.periodStart >= :ps', { ps: periodStart });
if (periodEnd) qb.andWhere('e.periodEnd <= :pe', { pe: periodEnd }); if (periodEnd) qb.andWhere('e.periodEnd <= :pe', { pe: periodEnd });
return qb.getRawMany(); return qb.getRawMany();
@@ -315,7 +298,6 @@ export class DashboardService {
// 班级考勤排行 // 班级考勤排行
async getClassAttendanceRanking() { async getClassAttendanceRanking() {
const scopeIds = await this.scope.getScopeDepartmentIds();
const qb = this.attendanceRepo const qb = this.attendanceRepo
.createQueryBuilder('a') .createQueryBuilder('a')
.leftJoin('a.class', 'class') .leftJoin('a.class', 'class')
@@ -324,7 +306,6 @@ export class DashboardService {
.addSelect('a.status', 'status') .addSelect('a.status', 'status')
.addSelect('COUNT(*)', 'count') .addSelect('COUNT(*)', 'count')
.groupBy('class.id').addGroupBy('class.name').addGroupBy('a.status'); .groupBy('class.id').addGroupBy('class.name').addGroupBy('a.status');
if (scopeIds) qb.andWhere('a.departmentId IN (:...scopeIds)', { scopeIds });
const raw = await qb.getRawMany(); const raw = await qb.getRawMany();
const classMap = new Map<number, { className: string; present: number; total: number }>(); const classMap = new Map<number, { className: string; present: number; total: number }>();
@@ -345,9 +326,8 @@ export class DashboardService {
} }
async getClassroomOccupancy() { async getClassroomOccupancy() {
const scopeIds = await this.scope.getScopeDepartmentIds();
const classrooms = await this.classroomRepo.find({ const classrooms = await this.classroomRepo.find({
where: await this.scope.filter({ status: Not('archived') }), where: { status: Not('archived') },
order: { building: 'ASC', name: 'ASC' }, order: { building: 'ASC', name: 'ASC' },
}); });
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
@@ -359,7 +339,6 @@ export class DashboardService {
.andWhere('s.scheduleType = :type', { type: 'INTERNAL' }) .andWhere('s.scheduleType = :type', { type: 'INTERNAL' })
.andWhere('s.startDate <= :today AND s.endDate >= :today', { today }) .andWhere('s.startDate <= :today AND s.endDate >= :today', { today })
.groupBy('s.classroomId'); .groupBy('s.classroomId');
if (scopeIds) schedQb.andWhere('s.departmentId IN (:...scopeIds)', { scopeIds });
const schedules = await schedQb.getRawMany(); const schedules = await schedQb.getRawMany();
const rentalQb = this.rentalRepo const rentalQb = this.rentalRepo
.createQueryBuilder('r') .createQueryBuilder('r')
@@ -368,7 +347,6 @@ export class DashboardService {
.where('r.status != :cancelled', { cancelled: 'cancelled' }) .where('r.status != :cancelled', { cancelled: 'cancelled' })
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today }) .andWhere('r.startDate <= :today AND r.endDate >= :today', { today })
.groupBy('r.classroomId'); .groupBy('r.classroomId');
if (scopeIds) rentalQb.andWhere('r.departmentId IN (:...scopeIds)', { scopeIds });
const rentals = await rentalQb.getRawMany(); const rentals = await rentalQb.getRawMany();
const sMap: Record<number, number> = {}; const sMap: Record<number, number> = {};
const rMap: Record<number, number> = {}; const rMap: Record<number, number> = {};
@@ -385,9 +363,8 @@ export class DashboardService {
} }
async getClassroomUtilizationStats() { async getClassroomUtilizationStats() {
const scopeIds = await this.scope.getScopeDepartmentIds();
const totalClassrooms = await this.classroomRepo.count({ 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); const today = new Date().toISOString().slice(0, 10);
@@ -399,7 +376,6 @@ export class DashboardService {
.where('s.status = :active', { active: 'active' }) .where('s.status = :active', { active: 'active' })
.andWhere('s.scheduleType = :type', { type: 'INTERNAL' }) .andWhere('s.scheduleType = :type', { type: 'INTERNAL' })
.andWhere('s.startDate <= :today AND s.endDate >= :today', { today }); .andWhere('s.startDate <= :today AND s.endDate >= :today', { today });
if (scopeIds) schedQb.andWhere('s.departmentId IN (:...scopeIds)', { scopeIds });
const schedResult = await schedQb.getRawOne(); const schedResult = await schedQb.getRawOne();
// Count classrooms with active rentals today // Count classrooms with active rentals today
@@ -408,7 +384,6 @@ export class DashboardService {
.select('COUNT(DISTINCT r.classroomId)', 'cnt') .select('COUNT(DISTINCT r.classroomId)', 'cnt')
.where('r.status != :cancelled', { cancelled: 'cancelled' }) .where('r.status != :cancelled', { cancelled: 'cancelled' })
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today }); .andWhere('r.startDate <= :today AND r.endDate >= :today', { today });
if (scopeIds) rentalQb.andWhere('r.departmentId IN (:...scopeIds)', { scopeIds });
const rentalResult = await rentalQb.getRawOne(); const rentalResult = await rentalQb.getRawOne();
// Combine: use Set merge of both // Combine: use Set merge of both
@@ -419,7 +394,6 @@ export class DashboardService {
.andWhere('s.scheduleType = :type', { type: 'INTERNAL' }) .andWhere('s.scheduleType = :type', { type: 'INTERNAL' })
.andWhere('s.startDate <= :today AND s.endDate >= :today', { today }) .andWhere('s.startDate <= :today AND s.endDate >= :today', { today })
.groupBy('s.classroomId'); .groupBy('s.classroomId');
if (scopeIds) combinedQb.andWhere('s.departmentId IN (:...scopeIds)', { scopeIds });
const schedIds = await combinedQb.getRawMany(); const schedIds = await combinedQb.getRawMany();
const combinedRentalQb = this.rentalRepo const combinedRentalQb = this.rentalRepo
@@ -428,7 +402,6 @@ export class DashboardService {
.where('r.status != :cancelled', { cancelled: 'cancelled' }) .where('r.status != :cancelled', { cancelled: 'cancelled' })
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today }) .andWhere('r.startDate <= :today AND r.endDate >= :today', { today })
.groupBy('r.classroomId'); .groupBy('r.classroomId');
if (scopeIds) combinedRentalQb.andWhere('r.departmentId IN (:...scopeIds)', { scopeIds });
const rentalIds = await combinedRentalQb.getRawMany(); const rentalIds = await combinedRentalQb.getRawMany();
const allInUseIds = new Set([ 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 { Deposit } from '../entities/deposit.entity';
import { Student } from '../entities/student.entity'; import { Student } from '../entities/student.entity';
import { DepositInstallment } from '../entities/deposit-installment.entity'; import { DepositInstallment } from '../entities/deposit-installment.entity';
import { CampusScope } from '../common/campus-scope';
import { CreateDepositDto, RefundDepositDto, CreateDepositWithInstallmentsDto } from './dto/deposit.dto'; import { CreateDepositDto, RefundDepositDto, CreateDepositWithInstallmentsDto } from './dto/deposit.dto';
@Injectable() @Injectable()
@@ -16,17 +16,14 @@ export class DepositsService {
private installmentRepo: Repository<DepositInstallment>, private installmentRepo: Repository<DepositInstallment>,
@InjectRepository(Student) @InjectRepository(Student)
private studentRepo: Repository<Student>, private studentRepo: Repository<Student>,
private readonly scope: CampusScope,
) {} ) {}
async findAll(query?: { studentId?: number; status?: string }) { async findAll(query?: { studentId?: number; status?: string }) {
const scopeIds = await this.scope.getScopeDepartmentIds();
const qb = this.repo const qb = this.repo
.createQueryBuilder('d') .createQueryBuilder('d')
.leftJoinAndSelect('d.student', 'student') .leftJoinAndSelect('d.student', 'student')
.leftJoinAndSelect('d.installments', 'installments') .leftJoinAndSelect('d.installments', 'installments')
.orderBy('d.createdAt', 'DESC'); .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?.studentId) qb.andWhere('d.studentId = :studentId', { studentId: query.studentId });
if (query?.status) qb.andWhere('d.status = :status', { status: query.status }); if (query?.status) qb.andWhere('d.status = :status', { status: query.status });
return qb.getMany(); return qb.getMany();
@@ -49,7 +46,6 @@ export class DepositsService {
status: 'paid', status: 'paid',
recordedBy: userId, recordedBy: userId,
}); });
deposit.departmentId = student.departmentId;
if (dto instanceof CreateDepositWithInstallmentsDto && dto.installments?.length) { if (dto instanceof CreateDepositWithInstallmentsDto && dto.installments?.length) {
deposit.installments = dto.installments.map((i) => { deposit.installments = dto.installments.map((i) => {
@@ -58,7 +54,6 @@ export class DepositsService {
dueDate: i.dueDate, dueDate: i.dueDate,
status: 'pending', status: 'pending',
}); });
inst.departmentId = student.departmentId;
return inst; return inst;
}); });
} }
@@ -174,11 +169,10 @@ export class DepositsService {
return this.repo.save(deposit); return this.repo.save(deposit);
} }
async findPendingRefunds() { async findPendingRefunds() {
const baseWhere = await this.scope.filter({});
return this.repo.find({ return this.repo.find({
where: [ where: [
{ ...baseWhere, refundStatus: 'pending' }, { refundStatus: 'pending' },
{ ...baseWhere, refundStatus: 'head_teacher_approved' }, { refundStatus: 'head_teacher_approved' },
], ],
relations: ['student', 'installments'], relations: ['student', 'installments'],
order: { refundRequestedAt: 'DESC' }, order: { refundRequestedAt: 'DESC' },
@@ -193,13 +187,11 @@ export class DepositsService {
} }
async getStats() { async getStats() {
const scopeIds = await this.scope.getScopeDepartmentIds();
const qb = this.repo const qb = this.repo
.createQueryBuilder('d') .createQueryBuilder('d')
.select('d.status', 'status') .select('d.status', 'status')
.addSelect('COUNT(*)', 'count') .addSelect('COUNT(*)', 'count')
.addSelect('SUM(d.amount)', 'totalAmount'); .addSelect('SUM(d.amount)', 'totalAmount');
if (scopeIds) qb.andWhere('d.departmentId IN (:...scopeIds)', { scopeIds });
qb.groupBy('d.status'); qb.groupBy('d.status');
return qb.getRawMany(); return qb.getRawMany();
} }

View File

@@ -8,7 +8,6 @@ import {
JoinColumn, JoinColumn,
} from 'typeorm'; } from 'typeorm';
import { Student } from './student.entity'; import { Student } from './student.entity';
import { Department } from './department.entity';
@Entity('archive_attachments') @Entity('archive_attachments')
export class ArchiveAttachment { export class ArchiveAttachment {
@@ -37,12 +36,6 @@ export class ArchiveAttachment {
@Column({ name: 'mime_type', length: 100, nullable: true }) @Column({ name: 'mime_type', length: 100, nullable: true })
mimeType: string; 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' }) @CreateDateColumn({ name: 'created_at' })
createdAt: Date; createdAt: Date;

View File

@@ -10,7 +10,6 @@ import {
} from 'typeorm'; } from 'typeorm';
import { Student } from './student.entity'; import { Student } from './student.entity';
import { Class } from './class.entity'; import { Class } from './class.entity';
import { Department } from './department.entity';
@Entity('attendance_records') @Entity('attendance_records')
@Index(['classId', 'attendanceDate']) @Index(['classId', 'attendanceDate'])
@@ -54,10 +53,4 @@ export class AttendanceRecord {
@UpdateDateColumn({ name: 'updated_at' }) @UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date; 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'; } from 'typeorm';
import { Student } from './student.entity'; import { Student } from './student.entity';
import { BillItem } from './bill-item.entity'; import { BillItem } from './bill-item.entity';
import { Department } from './department.entity';
@Entity('bills') @Entity('bills')
export class Bill { export class Bill {
@@ -47,10 +46,4 @@ export class Bill {
@OneToMany(() => BillItem, (bi) => bi.bill) @OneToMany(() => BillItem, (bi) => bi.bill)
items: BillItem[]; 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, JoinColumn,
Check, Check,
} from 'typeorm'; } from 'typeorm';
import { Department } from './department.entity';
export enum ScheduleType { export enum ScheduleType {
INTERNAL = 'INTERNAL', INTERNAL = 'INTERNAL',
@@ -81,10 +80,4 @@ export class ClassSchedule {
@UpdateDateColumn({ name: 'updated_at' }) @UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date; 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'; } from 'typeorm';
import { Classroom } from './classroom.entity'; import { Classroom } from './classroom.entity';
import { Tenant } from './tenant.entity'; import { Tenant } from './tenant.entity';
import { Department } from './department.entity';
@Entity('classroom_rentals') @Entity('classroom_rentals')
@Index(['classroomId', 'startDate', 'endDate']) @Index(['classroomId', 'startDate', 'endDate'])
@@ -66,10 +65,4 @@ export class ClassroomRental {
@UpdateDateColumn({ name: 'updated_at' }) @UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date; 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 { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
import { Department } from './department.entity';
@Entity('classrooms') @Entity('classrooms')
export class Classroom { export class Classroom {
@@ -36,10 +35,4 @@ export class Classroom {
@CreateDateColumn({ name: 'created_at' }) @CreateDateColumn({ name: 'created_at' })
createdAt: Date; 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, JoinColumn,
} from 'typeorm'; } from 'typeorm';
import { Deposit } from './deposit.entity'; import { Deposit } from './deposit.entity';
import { Department } from './department.entity';
@Entity('deposit_installments') @Entity('deposit_installments')
export class DepositInstallment { export class DepositInstallment {
@@ -36,10 +35,4 @@ export class DepositInstallment {
@JoinColumn({ name: 'deposit_id' }) @JoinColumn({ name: 'deposit_id' })
deposit: Deposit; 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'; } from 'typeorm';
import { Student } from './student.entity'; import { Student } from './student.entity';
import { DepositInstallment } from './deposit-installment.entity'; import { DepositInstallment } from './deposit-installment.entity';
import { Department } from './department.entity';
@Entity('deposits') @Entity('deposits')
export class Deposit { export class Deposit {
@@ -72,10 +71,4 @@ export class Deposit {
@JoinColumn({ name: 'student_id' }) @JoinColumn({ name: 'student_id' })
student: Student; 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'; } from 'typeorm';
import { Student } from './student.entity'; import { Student } from './student.entity';
import { StudentEnrollment } from './student-enrollment.entity'; import { StudentEnrollment } from './student-enrollment.entity';
import { Department } from './department.entity';
@Entity('exam_scores') @Entity('exam_scores')
export class ExamScore { export class ExamScore {
@@ -51,12 +50,6 @@ export class ExamScore {
@Column({ name: 'exam_date', type: 'date', nullable: true }) @Column({ name: 'exam_date', type: 'date', nullable: true })
examDate: string; 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' }) @CreateDateColumn({ name: 'created_at' })
createdAt: Date; createdAt: Date;

View File

@@ -26,8 +26,6 @@ export { SyncLog } from './sync-log.entity';
export { SyncState } from './sync-state.entity'; export { SyncState } from './sync-state.entity';
export { ExpenseType } from './expense-type.entity'; export { ExpenseType } from './expense-type.entity';
export { Notification, NotificationType } from './notification.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 { StudentProfile } from './student-profile.entity';
export { StudentEnrollment } from './student-enrollment.entity'; export { StudentEnrollment } from './student-enrollment.entity';
export { ExamScore } from './exam-score.entity'; export { ExamScore } from './exam-score.entity';

View File

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

View File

@@ -11,7 +11,6 @@ import { Room } from './room.entity';
import { Tenant } from './tenant.entity'; import { Tenant } from './tenant.entity';
import { Bed } from './bed.entity'; import { Bed } from './bed.entity';
import { Locker } from './locker.entity'; import { Locker } from './locker.entity';
import { Department } from './department.entity';
@Entity('occupancies') @Entity('occupancies')
export class Occupancy { export class Occupancy {
@@ -77,10 +76,4 @@ export class Occupancy {
@JoinColumn({ name: 'room_id' }) @JoinColumn({ name: 'room_id' })
room: Room; 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, JoinColumn,
} from 'typeorm'; } from 'typeorm';
import { Student } from './student.entity'; import { Student } from './student.entity';
import { Department } from './department.entity';
@Entity('personal_expenses') @Entity('personal_expenses')
export class PersonalExpense { export class PersonalExpense {
@@ -42,10 +41,4 @@ export class PersonalExpense {
@JoinColumn({ name: 'student_id' }) @JoinColumn({ name: 'student_id' })
student: Student; 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, JoinColumn,
} from 'typeorm'; } from 'typeorm';
import { Student } from './student.entity'; import { Student } from './student.entity';
import { Department } from './department.entity';
@Entity('result_archives') @Entity('result_archives')
export class ResultArchive { export class ResultArchive {
@@ -37,12 +36,6 @@ export class ResultArchive {
@Column({ name: 'admitted_major', length: 100, nullable: true }) @Column({ name: 'admitted_major', length: 100, nullable: true })
admittedMajor: string; 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' }) @CreateDateColumn({ name: 'created_at' })
createdAt: Date; createdAt: Date;

View File

@@ -7,7 +7,6 @@ import {
JoinColumn, JoinColumn,
} from 'typeorm'; } from 'typeorm';
import { Room } from './room.entity'; import { Room } from './room.entity';
import { Department } from './department.entity';
@Entity('room_expenses') @Entity('room_expenses')
export class RoomExpense { export class RoomExpense {
@@ -42,10 +41,4 @@ export class RoomExpense {
@JoinColumn({ name: 'room_id' }) @JoinColumn({ name: 'room_id' })
room: Room; 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 { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, OneToMany, ManyToOne, JoinColumn } from 'typeorm';
import { Occupancy } from './occupancy.entity'; import { Occupancy } from './occupancy.entity';
import { RoomExpense } from './room-expense.entity'; import { RoomExpense } from './room-expense.entity';
import { Department } from './department.entity';
@Entity('rooms') @Entity('rooms')
export class Room { export class Room {
@@ -44,10 +43,4 @@ export class Room {
@OneToMany(() => RoomExpense, (e) => e.room) @OneToMany(() => RoomExpense, (e) => e.room)
roomExpenses: RoomExpense[]; 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, JoinColumn,
} from 'typeorm'; } from 'typeorm';
import { Student } from './student.entity'; import { Student } from './student.entity';
import { Department } from './department.entity';
@Entity('student_enrollments') @Entity('student_enrollments')
export class StudentEnrollment { export class StudentEnrollment {
@@ -46,12 +45,6 @@ export class StudentEnrollment {
@Column({ length: 20, default: 'active' }) @Column({ length: 20, default: 'active' })
status: string; 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' }) @CreateDateColumn({ name: 'created_at' })
createdAt: Date; createdAt: Date;

View File

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

View File

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

View File

@@ -14,7 +14,6 @@ import { PersonalExpense } from './personal-expense.entity';
import { Bill } from './bill.entity'; import { Bill } from './bill.entity';
import { Tenant } from './tenant.entity'; import { Tenant } from './tenant.entity';
import { User } from './user.entity'; import { User } from './user.entity';
import { Department } from './department.entity';
@Entity('students') @Entity('students')
export class Student { export class Student {
@@ -82,10 +81,4 @@ export class Student {
@OneToMany(() => Bill, (b) => b.student) @OneToMany(() => Bill, (b) => b.student)
bills: Bill[]; 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, BatchRoomExpenseDto,
} from './dto/expense.dto'; } from './dto/expense.dto';
import { RoomsService } from '../rooms/rooms.service'; import { RoomsService } from '../rooms/rooms.service';
import { CampusScope } from '../common/campus-scope';
@Injectable() @Injectable()
export class ExpensesService { export class ExpensesService {
@@ -20,7 +20,6 @@ export class ExpensesService {
@InjectRepository(PersonalExpense) private personalExpRepo: Repository<PersonalExpense>, @InjectRepository(PersonalExpense) private personalExpRepo: Repository<PersonalExpense>,
@InjectRepository(Room) private roomRepo: Repository<Room>, @InjectRepository(Room) private roomRepo: Repository<Room>,
@InjectRepository(Student) private studentRepo: Repository<Student>, @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 } }); const room = await this.roomRepo.findOne({ where: { id: dto.roomId } });
if (!room) throw new NotFoundException('宿舍不存在'); if (!room) throw new NotFoundException('宿舍不存在');
const entity = this.roomExpRepo.create({ ...dto, recordedBy: userId }); const entity = this.roomExpRepo.create({ ...dto, recordedBy: userId });
entity.departmentId = room.departmentId;
return this.roomExpRepo.save(entity); return this.roomExpRepo.save(entity);
} }
async batchCreateRoomExpenses(dto: BatchRoomExpenseDto, userId?: number) { 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 entities = dto.expenses.map((e) => {
const entity = this.roomExpRepo.create({ const entity = this.roomExpRepo.create({
roomId: e.roomId, roomId: e.roomId,
@@ -46,19 +41,16 @@ export class ExpensesService {
periodEnd: dto.periodEnd, periodEnd: dto.periodEnd,
recordedBy: userId, recordedBy: userId,
}); });
entity.departmentId = roomDeptMap.get(e.roomId)!;
return entity; return entity;
}); });
return this.roomExpRepo.save(entities); return this.roomExpRepo.save(entities);
} }
async findRoomExpenses(query?: { roomId?: number; periodStart?: string; periodEnd?: string }) { async findRoomExpenses(query?: { roomId?: number; periodStart?: string; periodEnd?: string }) {
const scopeIds = await this.scope.getScopeDepartmentIds();
const qb = this.roomExpRepo const qb = this.roomExpRepo
.createQueryBuilder('e') .createQueryBuilder('e')
.leftJoinAndSelect('e.room', 'room') .leftJoinAndSelect('e.room', 'room')
.orderBy('e.createdAt', 'DESC'); .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?.roomId) qb.andWhere('e.roomId = :roomId', { roomId: query.roomId });
if (query?.periodStart) qb.andWhere('e.periodStart >= :ps', { ps: query.periodStart }); if (query?.periodStart) qb.andWhere('e.periodStart >= :ps', { ps: query.periodStart });
if (query?.periodEnd) qb.andWhere('e.periodEnd <= :pe', { pe: query.periodEnd }); 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 } }); const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
if (!student) throw new NotFoundException('学生不存在'); if (!student) throw new NotFoundException('学生不存在');
const entity = this.personalExpRepo.create({ ...dto, recordedBy: userId }); const entity = this.personalExpRepo.create({ ...dto, recordedBy: userId });
entity.departmentId = student.departmentId;
return this.personalExpRepo.save(entity); return this.personalExpRepo.save(entity);
} }
async findPersonalExpenses(query?: { studentId?: number }) { async findPersonalExpenses(query?: { studentId?: number }) {
let where: Record<string, unknown> = {}; const where: Record<string, unknown> = {};
if (query?.studentId) where.studentId = query.studentId; if (query?.studentId) where.studentId = query.studentId;
where = await this.scope.filter(where);
return this.personalExpRepo.find({ return this.personalExpRepo.find({
where, where,
relations: ['student'], relations: ['student'],

View File

@@ -3,13 +3,11 @@
* *
* 提供: * 提供:
* - OAuth2 access_token新版 API + 缓存) * - OAuth2 access_token新版 API + 缓存)
* - BFS 遍历所有部门 + 用户(带限流)
* - 用户同步(自动建 Student + StudentDingMapping * - 用户同步(自动建 Student + StudentDingMapping
*/ */
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { Department } from '../entities/department.entity';
import { Student } from '../entities/student.entity'; import { Student } from '../entities/student.entity';
import { StudentDingMapping } from '../entities/student-ding-mapping.entity'; import { StudentDingMapping } from '../entities/student-ding-mapping.entity';
@@ -20,18 +18,6 @@ interface DingTalkTokenResponse {
expireIn: number; 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 { interface DingTalkUserListResponse {
errcode: number; errcode: number;
errmsg: string; errmsg: string;
@@ -61,28 +47,6 @@ export interface DingTalkAttendanceResult {
checkType: string; 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 类型 ── // ── 考勤排班 API 类型 ──
/** 班次卡段打卡时间 */ /** 班次卡段打卡时间 */
@@ -182,8 +146,6 @@ export class DingTalkService {
private static readonly MIN_INTERVAL = 1000 / DingTalkService.RATE_LIMIT; private static readonly MIN_INTERVAL = 1000 / DingTalkService.RATE_LIMIT;
constructor( constructor(
@InjectRepository(Department)
private readonly deptRepo: Repository<Department>,
@InjectRepository(Student) @InjectRepository(Student)
private readonly studentRepo: Repository<Student>, private readonly studentRepo: Repository<Student>,
@InjectRepository(StudentDingMapping) @InjectRepository(StudentDingMapping)
@@ -222,65 +184,6 @@ export class DingTalkService {
return this.accessToken; 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 // Users by department — 对齐 gongxue-dorm-sys getUsersByDepartment
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
@@ -334,191 +237,39 @@ export class DingTalkService {
const t0 = Date.now(); const t0 = Date.now();
const token = await this.getAccessToken(); const token = await this.getAccessToken();
// ── Step 1: BFS traverse all departments ── // ── Sync users from root department ──
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 ──
let userCount = 0; let userCount = 0;
const seenUserIds = new Set<string>(); const seenUserIds = new Set<string>();
for (let i = 0; i < deptIds.length; i++) { const dingUsers = await this.getDeptUsers(token, rootDeptId);
const deptId = deptIds[i]; for (const du of dingUsers) {
const dingUsers = await this.getDeptUsers(token, deptId); if (seenUserIds.has(du.userid)) continue;
seenUserIds.add(du.userid);
for (const du of dingUsers) { await this.syncOneUser(du);
if (seenUserIds.has(du.userid)) continue; userCount++;
seenUserIds.add(du.userid);
await this.syncOneUser(du);
userCount++;
}
} }
this.logger.log( 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[]> { async fetchOrgTree(_rootDeptId = 1): Promise<[]> {
if (!this.configured) { return [];
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;
} }
/** /**
* 获取钉钉组织部门树(含用户),供前端同步用户选择器使用 * 获取钉钉组织部门树(含用户)。
* 返回从指定 rootDeptId 开始的树,每个部门节点含 users 数组。 * ponytail: Department entity removed; returns empty array.
*/ */
async fetchOrgTreeWithUsers(rootDeptId = 1): Promise<DingOrgTreeNodeWithUsers[]> { async fetchOrgTreeWithUsers(_rootDeptId = 1): Promise<[]> {
if (!this.configured) { return [];
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;
} }
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════

View File

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

View File

@@ -1,7 +1,6 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { Department } from '../entities/department.entity';
import { User } from '../entities/user.entity'; import { User } from '../entities/user.entity';
interface WeComTokenResponse { interface WeComTokenResponse {
@@ -35,8 +34,6 @@ export class WeComService {
private tokenExpiresAt = 0; private tokenExpiresAt = 0;
constructor( constructor(
@InjectRepository(Department)
private readonly deptRepo: Repository<Department>,
@InjectRepository(User) @InjectRepository(User)
private readonly userRepo: Repository<User>, private readonly userRepo: Repository<User>,
) {} ) {}
@@ -97,45 +94,14 @@ export class WeComService {
return body.userlist; return body.userlist;
} }
async syncAll(): Promise<{ deptCount: number; userCount: number }> { async syncAll(): Promise<{ userCount: number }> {
if (!this.configured) { if (!this.configured) {
this.logger.warn('WeCom not configured (WECOM_CORP_ID / WECOM_CORP_SECRET missing), skipping sync'); 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 token = await this.getAccessToken();
const wxDepts = await this.fetchDepartments(token); 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; let userCount = 0;
const seenUserIds = new Set<string>(); const seenUserIds = new Set<string>();
for (const wd of wxDepts) { for (const wd of wxDepts) {
@@ -159,7 +125,7 @@ export class WeComService {
} }
} }
this.logger.log(`WeCom sync done: ${deptCount} new depts, ${userCount} new users`); this.logger.log(`WeCom sync done: ${userCount} new users`);
return { deptCount, userCount }; return { userCount };
} }
} }

View File

@@ -1,16 +1,12 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { seedDefaultCampus } from './departments/seed';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api'); app.setGlobalPrefix('api');
app.enableCors(); app.enableCors();
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
const dataSource = app.get(DataSource);
await seedDefaultCampus(dataSource);
await app.listen(process.env.PORT ?? 3000); await app.listen(process.env.PORT ?? 3000);
console.log(`Server running on http://localhost:${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 { Deposit } from '../entities/deposit.entity';
import { CheckInDto, CheckOutDto, TransferRoomDto } from './dto/occupancy.dto'; import { CheckInDto, CheckOutDto, TransferRoomDto } from './dto/occupancy.dto';
import { RoomsService } from '../rooms/rooms.service'; import { RoomsService } from '../rooms/rooms.service';
import { CampusScope } from '../common/campus-scope';
@Injectable() @Injectable()
export class OccupanciesService { export class OccupanciesService {
@@ -29,7 +29,6 @@ export class OccupanciesService {
@InjectRepository(Bed) private bedRepo: Repository<Bed>, @InjectRepository(Bed) private bedRepo: Repository<Bed>,
@InjectRepository(Locker) private lockerRepo: Repository<Locker>, @InjectRepository(Locker) private lockerRepo: Repository<Locker>,
private dataSource: DataSource, private dataSource: DataSource,
private readonly scope: CampusScope,
) {} ) {}
async findAll(query?: { roomId?: number; studentId?: number; active?: boolean }) { async findAll(query?: { roomId?: number; studentId?: number; active?: boolean }) {
@@ -40,10 +39,6 @@ export class OccupanciesService {
.leftJoinAndSelect('o.bed', 'bed') .leftJoinAndSelect('o.bed', 'bed')
.leftJoinAndSelect('o.locker', 'locker') .leftJoinAndSelect('o.locker', 'locker')
.orderBy('o.checkInDate', 'DESC'); .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?.roomId) qb.andWhere('o.roomId = :roomId', { roomId: query.roomId });
if (query?.studentId) qb.andWhere('o.studentId = :studentId', { studentId: query.studentId }); if (query?.studentId) qb.andWhere('o.studentId = :studentId', { studentId: query.studentId });
if (query?.active) qb.andWhere('o.checkOutDate IS NULL'); if (query?.active) qb.andWhere('o.checkOutDate IS NULL');
@@ -97,7 +92,6 @@ export class OccupanciesService {
bedId: dto.bedId, bedId: dto.bedId,
lockerId: dto.lockerId, lockerId: dto.lockerId,
}); });
occ.departmentId = room.departmentId;
const saved = await this.repo.save(occ); const saved = await this.repo.save(occ);
// 更新床位/柜子状态 // 更新床位/柜子状态

View File

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

View File

@@ -360,8 +360,7 @@ export class RoomsController {
monthlyRate, 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);
const result = await this.service.batchImport(rows, departmentId);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,

View File

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

View File

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

View File

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

View File

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

View File

@@ -44,8 +44,6 @@ export class CreateStudentDto {
@IsString() @IsString()
supervisor?: string; supervisor?: string;
@IsOptional() @IsOptional()
@IsInt()
departmentId?: number;
@IsOptional() @IsOptional()
@IsInt() @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);
const result = await this.service.batchImport(rows, departmentId);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,

View File

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

View File

@@ -3,10 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Not, IsNull } from 'typeorm'; import { Repository, Not, IsNull } from 'typeorm';
import { import {
ClassSchedule, ClassSchedule,
ClassStudent,
ClassTeacher, ClassTeacher,
Department,
UserDepartment,
} from '../entities'; } from '../entities';
import { DingTalkService, DingTalkScheduleItem } from '../integration/dingtalk.service'; import { DingTalkService, DingTalkScheduleItem } from '../integration/dingtalk.service';
@@ -48,10 +45,6 @@ export class ScheduleSyncService {
private readonly scheduleRepo: Repository<ClassSchedule>, private readonly scheduleRepo: Repository<ClassSchedule>,
@InjectRepository(ClassTeacher) @InjectRepository(ClassTeacher)
private readonly classTeacherRepo: Repository<ClassTeacher>, private readonly classTeacherRepo: Repository<ClassTeacher>,
@InjectRepository(Department)
private readonly deptRepo: Repository<Department>,
@InjectRepository(UserDepartment)
private readonly userDeptRepo: Repository<UserDepartment>,
private readonly dingTalkService: DingTalkService, private readonly dingTalkService: DingTalkService,
) {} ) {}
@@ -75,7 +68,6 @@ export class ScheduleSyncService {
status: 'active', status: 'active',
teacherId: Not(IsNull()), teacherId: Not(IsNull()),
}, },
relations: ['department'],
}); });
if (schedules.length === 0) { if (schedules.length === 0) {
@@ -110,102 +102,84 @@ export class ScheduleSyncService {
} }
} }
// ── Step 4: 按部门分组 ── // ── Step 4: Collect teacher IDs (no department entity) ──
const deptGroups = new Map< const teacherIds = new Set<number>();
number,
{ deptName: string; schedules: ClassSchedule[]; teacherIds: Set<number> }
>();
for (const s of schedules) { for (const s of schedules) {
const deptId = s.departmentId || 0; if (s.teacherId) teacherIds.add(s.teacherId);
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);
} }
// ── Step 5: 每个部门 → 考勤组 → 排班 ── // ── Step 5: Single group → attendance group → scheduling ──
let syncedItems = 0; let syncedItems = 0;
let skippedNoMapping = 0; let skippedNoMapping = 0;
let groupCount = 0; let groupCount = 0;
const groupDetails: ScheduleSyncResult['groups'] = []; const groupDetails: ScheduleSyncResult['groups'] = [];
for (const [deptId, group] of deptGroups) { // Collect teacher→ding mapping
// 获取该部门教师的钉钉 userIds const dingUserIds: string[] = [];
const dingUserIds: string[] = []; const teacherDingMap = new Map<number, string>();
const teacherDingMap = new Map<number, string>(); // local teacherId → dingUserId for (const tid of teacherIds) {
for (const tid of group.teacherIds) { const dingId = userIdToDingId.get(tid);
const dingId = userIdToDingId.get(tid); if (dingId) {
if (dingId) { dingUserIds.push(dingId);
dingUserIds.push(dingId); teacherDingMap.set(tid, 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( this.logger.log(
`排班同步完成: ${schedules.length} 条排课 → ${syncedItems} 条钉钉排班, ` + `排班同步完成: ${schedules.length} 条排课 → ${syncedItems} 条钉钉排班, ` +
`${shiftCount} 班次, ${groupCount} 考勤组, 跳过 ${skippedNoMapping} 条无映射`, `${shiftCount} 班次, ${groupCount} 考勤组, 跳过 ${skippedNoMapping} 条无映射`,

View File

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

View File

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