forked from wangziqi/gongxue-base
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:
@@ -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 -->
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"mcp__codegraph__*"
|
||||
]
|
||||
},
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "codegraph prompt-hook"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -51,3 +51,6 @@ apps/admin/node_modules/
|
||||
apps/admin/dist/
|
||||
.codegraph/
|
||||
.worktrees/
|
||||
.claude/
|
||||
.omp/
|
||||
.superpowers/
|
||||
|
||||
@@ -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/` 下,每个模块独立目录
|
||||
@@ -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.
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
@@ -32,8 +32,6 @@ import {
|
||||
SyncLog,
|
||||
SyncState,
|
||||
Notification,
|
||||
Department,
|
||||
UserDepartment,
|
||||
StudentProfile,
|
||||
StudentEnrollment,
|
||||
ExamScore,
|
||||
@@ -63,7 +61,6 @@ import { SchedulesModule } from './schedules/schedules.module';
|
||||
import { ClassroomRentalsModule } from './classroom-rentals/classroom-rentals.module';
|
||||
import { SyncModule } from './sync/sync.module';
|
||||
import { NotificationsModule } from './notifications/notifications.module';
|
||||
import { DepartmentsModule } from './departments/departments.module';
|
||||
import { CommonModule } from './common/common.module';
|
||||
import { ArchiveModule } from './archive/archive.module';
|
||||
import { ExpenseTypesModule } from './expense-types/expense-types.module';
|
||||
@@ -71,7 +68,6 @@ import { ExpenseTypesModule } from './expense-types/expense-types.module';
|
||||
import { IntegrationConfig, IntegrationConfigDetail } from './integration/entities/integration-config.entity';
|
||||
import { IntegrationConfigModule } from './integration/config/config.module';
|
||||
|
||||
import { CampusScopeMiddleware } from './common/campus-scope.middleware';
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
@@ -113,8 +109,6 @@ import { CampusScopeMiddleware } from './common/campus-scope.middleware';
|
||||
AttendanceRecord,
|
||||
DingAttendanceRaw,
|
||||
Notification,
|
||||
Department,
|
||||
UserDepartment,
|
||||
StudentProfile,
|
||||
StudentEnrollment,
|
||||
ExamScore,
|
||||
@@ -167,7 +161,6 @@ import { CampusScopeMiddleware } from './common/campus-scope.middleware';
|
||||
ClassroomRentalsModule,
|
||||
SyncModule,
|
||||
NotificationsModule,
|
||||
DepartmentsModule,
|
||||
CommonModule,
|
||||
ArchiveModule,
|
||||
IntegrationConfigModule,
|
||||
@@ -179,8 +172,4 @@ import { CampusScopeMiddleware } from './common/campus-scope.middleware';
|
||||
{ provide: APP_GUARD, useClass: PermissionGuard },
|
||||
],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer.apply(CampusScopeMiddleware).forRoutes('*');
|
||||
}
|
||||
}
|
||||
export class AppModule {}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { CampusScope } from '../common/campus-scope';
|
||||
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { StudentProfile } from '../entities/student-profile.entity';
|
||||
@@ -30,7 +30,6 @@ export class ArchiveService {
|
||||
@InjectRepository(LearningRecord) private learningRecordRepo: Repository<LearningRecord>,
|
||||
@InjectRepository(ResultArchive) private resultRepo: Repository<ResultArchive>,
|
||||
@InjectRepository(ArchiveAttachment) private attachmentRepo: Repository<ArchiveAttachment>,
|
||||
private readonly scope: CampusScope,
|
||||
private readonly notificationsService: NotificationsService,
|
||||
) {}
|
||||
|
||||
@@ -46,24 +45,12 @@ export class ArchiveService {
|
||||
resultArchive,
|
||||
attachments,
|
||||
] = await Promise.all([
|
||||
this.profileRepo.findOne({ where: await this.scope.filter({ studentId }) }),
|
||||
this.enrollmentRepo.find({
|
||||
where: await this.scope.filter({ studentId }),
|
||||
order: { createdAt: 'DESC' },
|
||||
}),
|
||||
this.examScoreRepo.find({
|
||||
where: await this.scope.filter({ studentId }),
|
||||
order: { examDate: 'DESC' },
|
||||
}),
|
||||
this.learningRecordRepo.find({
|
||||
where: await this.scope.filter({ studentId }),
|
||||
order: { recordDate: 'DESC' },
|
||||
}),
|
||||
this.resultRepo.findOne({ where: await this.scope.filter({ studentId }) }),
|
||||
this.attachmentRepo.find({
|
||||
where: await this.scope.filter({ studentId }),
|
||||
order: { createdAt: 'DESC' },
|
||||
}),
|
||||
this.profileRepo.findOne({ where: { studentId } }),
|
||||
this.enrollmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
|
||||
this.examScoreRepo.find({ where: { studentId }, order: { examDate: 'DESC' } }),
|
||||
this.learningRecordRepo.find({ where: { studentId }, order: { recordDate: 'DESC' } }),
|
||||
this.resultRepo.findOne({ where: { studentId } }),
|
||||
this.attachmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -43,10 +43,6 @@ export class AttendanceService {
|
||||
throw new BadRequestException('records array must not be empty');
|
||||
}
|
||||
|
||||
// Batch-load student departmentIds
|
||||
const ids = [...new Set(dto.records.map((r) => r.studentId))];
|
||||
const students = await this.studentRepo.find({ where: { id: In(ids) } });
|
||||
const deptMap = new Map(students.map((s) => [s.id, s.departmentId]));
|
||||
|
||||
const entities = dto.records.map((r) => {
|
||||
const entity = this.attendanceRepo.create({
|
||||
@@ -58,7 +54,6 @@ export class AttendanceService {
|
||||
remark: r.remark,
|
||||
source: r.source || 'manual',
|
||||
});
|
||||
entity.departmentId = deptMap.get(r.studentId)!;
|
||||
return entity;
|
||||
});
|
||||
|
||||
@@ -128,7 +123,6 @@ export class AttendanceService {
|
||||
status: 'pending',
|
||||
source: 'schedule',
|
||||
});
|
||||
entity.departmentId = cs.student?.departmentId ?? undefined;
|
||||
entities.push(entity);
|
||||
existingKeys.add(key);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { PersonalExpense } from '../entities/personal-expense.entity';
|
||||
import { Occupancy } from '../entities/occupancy.entity';
|
||||
import { Room } from '../entities/room.entity';
|
||||
import { Deposit } from '../entities/deposit.entity';
|
||||
import { CampusScope } from '../common/campus-scope';
|
||||
|
||||
type MockRepository<T> = Partial<Record<keyof Repository<T>, jest.Mock>>;
|
||||
|
||||
@@ -70,7 +69,6 @@ describe('BillsService — generateBills', () => {
|
||||
{ provide: getRepositoryToken(Room), useValue: roomRepo },
|
||||
{ provide: getRepositoryToken(Deposit), useValue: depositRepo },
|
||||
{ provide: DataSource, useValue: dataSource },
|
||||
{ provide: CampusScope, useValue: { getScopeDepartmentIds: jest.fn().mockResolvedValue(null), filter: jest.fn((w: any) => w) } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Occupancy } from '../entities/occupancy.entity';
|
||||
import { Room } from '../entities/room.entity';
|
||||
import { Deposit } from '../entities/deposit.entity';
|
||||
import { GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto';
|
||||
import { CampusScope } from '../common/campus-scope';
|
||||
|
||||
|
||||
@Injectable()
|
||||
export class BillsService {
|
||||
@@ -22,7 +22,6 @@ export class BillsService {
|
||||
@InjectRepository(Room) private roomRepo: Repository<Room>,
|
||||
@InjectRepository(Deposit) private depositRepo: Repository<Deposit>,
|
||||
private dataSource: DataSource,
|
||||
private readonly scope: CampusScope,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -181,15 +180,6 @@ export class BillsService {
|
||||
// 合并所有涉及的学生
|
||||
const allStudentIds = new Set([...studentBillData.keys(), ...personalMap.keys()]);
|
||||
// 生成账单
|
||||
// Batch-load student departmentIds
|
||||
const studentIdsArr = [...allStudentIds];
|
||||
const studentDeptMap = new Map<number, number>();
|
||||
if (studentIdsArr.length > 0) {
|
||||
const studentsData: { id: number; department_id: number | null }[] = await this.dataSource.query(
|
||||
`SELECT id, department_id FROM students WHERE id IN (${studentIdsArr.join(',')})`
|
||||
);
|
||||
for (const s of studentsData) if (s.department_id != null) studentDeptMap.set(s.id, s.department_id);
|
||||
}
|
||||
const bills: Bill[] = [];
|
||||
for (const studentId of allStudentIds) {
|
||||
const shared = studentBillData.get(studentId)?.shared || 0;
|
||||
@@ -204,7 +194,6 @@ export class BillsService {
|
||||
personalAmount: personal,
|
||||
totalAmount: total,
|
||||
status: 'draft',
|
||||
departmentId: studentDeptMap.get(studentId),
|
||||
});
|
||||
const savedBill = await this.billRepo.save(bill);
|
||||
|
||||
@@ -229,12 +218,10 @@ export class BillsService {
|
||||
status?: string;
|
||||
expenseType?: string;
|
||||
}) {
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
const qb = this.billRepo
|
||||
.createQueryBuilder('b')
|
||||
.leftJoinAndSelect('b.student', 'student')
|
||||
.orderBy('b.generatedAt', 'DESC');
|
||||
if (scopeIds) qb.andWhere('b.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
if (query?.periodStart) qb.andWhere('b.periodStart = :ps', { ps: query.periodStart });
|
||||
if (query?.periodEnd) qb.andWhere('b.periodEnd = :pe', { pe: query.periodEnd });
|
||||
if (query?.studentId) qb.andWhere('b.studentId = :sid', { sid: query.studentId });
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { CommonModule } from '../common/common.module';
|
||||
import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, Department, Student, StudentDingMapping } from '../entities';
|
||||
import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, Student, StudentDingMapping } from '../entities';
|
||||
import { ClassesService } from './classes.service';
|
||||
import { ClassesController } from './classes.controller';
|
||||
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, Department, Student, StudentDingMapping]), OperationLogsModule, NotificationsModule, CommonModule],
|
||||
imports: [TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, Student, StudentDingMapping]), OperationLogsModule, NotificationsModule, CommonModule],
|
||||
controllers: [ClassesController],
|
||||
providers: [ClassesService],
|
||||
exports: [ClassesService],
|
||||
|
||||
@@ -147,7 +147,6 @@ export class ClassesService {
|
||||
this.studentRepo.create({
|
||||
name: `dd_${dingUserId}`,
|
||||
status: 'active',
|
||||
departmentId: undefined,
|
||||
})
|
||||
);
|
||||
const savedStudents = await this.studentRepo.save(newStudents);
|
||||
|
||||
@@ -7,7 +7,6 @@ import { ClassroomRental } from '../entities/classroom-rental.entity';
|
||||
import { Classroom } from '../entities/classroom.entity';
|
||||
import { Tenant } from '../entities/tenant.entity';
|
||||
import { ClassSchedule } from '../entities/class-schedule.entity';
|
||||
import { CampusScope } from '../common/campus-scope';
|
||||
import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto';
|
||||
|
||||
function mockQueryBuilder<T>(results: T[] = []) {
|
||||
@@ -33,7 +32,6 @@ describe('ClassroomRentalsService — findConflicts', () => {
|
||||
{ provide: getRepositoryToken(Classroom), useValue: {} },
|
||||
{ provide: getRepositoryToken(Tenant), useValue: {} },
|
||||
{ provide: getRepositoryToken(ClassSchedule), useValue: { createQueryBuilder: jest.fn() } },
|
||||
{ provide: CampusScope, useValue: { getScopeDepartmentIds: jest.fn().mockResolvedValue(null) } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -134,7 +132,6 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
{ provide: getRepositoryToken(Classroom), useValue: classroomRepo },
|
||||
{ provide: getRepositoryToken(Tenant), useValue: tenantRepo },
|
||||
{ provide: getRepositoryToken(ClassSchedule), useValue: scheduleRepo },
|
||||
{ provide: CampusScope, useValue: { getScopeDepartmentIds: jest.fn().mockResolvedValue(null) } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Classroom } from '../entities/classroom.entity';
|
||||
import { Tenant } from '../entities/tenant.entity';
|
||||
import { ClassSchedule } from '../entities/class-schedule.entity';
|
||||
import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto';
|
||||
import { CampusScope } from '../common/campus-scope';
|
||||
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
@@ -37,7 +37,6 @@ export class ClassroomRentalsService {
|
||||
@InjectRepository(Classroom) private classroomRepo: Repository<Classroom>,
|
||||
@InjectRepository(Tenant) private tenantRepo: Repository<Tenant>,
|
||||
@InjectRepository(ClassSchedule) private scheduleRepo: Repository<ClassSchedule>,
|
||||
private readonly scope: CampusScope,
|
||||
) {}
|
||||
|
||||
get uploadDir(): string {
|
||||
@@ -57,13 +56,11 @@ export class ClassroomRentalsService {
|
||||
month?: string;
|
||||
includeEnded?: boolean;
|
||||
}) {
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
const qb = this.repo
|
||||
.createQueryBuilder('r')
|
||||
.leftJoinAndSelect('r.classroom', 'classroom')
|
||||
.leftJoinAndSelect('r.tenant', 'tenant')
|
||||
.orderBy('r.startDate', 'DESC');
|
||||
if (scopeIds) qb.andWhere('r.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
if (query?.classroomId) qb.andWhere('r.classroomId = :cid', { cid: query.classroomId });
|
||||
if (query?.tenantId) qb.andWhere('r.tenantId = :tid', { tid: query.tenantId });
|
||||
if (query?.month) {
|
||||
@@ -142,9 +139,7 @@ export class ClassroomRentalsService {
|
||||
});
|
||||
}
|
||||
const rental = this.repo.create({ ...dto, createdBy: userId, status: 'active' });
|
||||
rental.departmentId = classroom.departmentId;
|
||||
const saved = await this.repo.save(rental);
|
||||
await this.syncScheduleFromRental(saved, tenant.name);
|
||||
return saved;
|
||||
}
|
||||
|
||||
@@ -221,7 +216,6 @@ export class ClassroomRentalsService {
|
||||
rentalId: rental.id,
|
||||
status: 'active',
|
||||
notes: rental.notes,
|
||||
departmentId: rental.departmentId,
|
||||
};
|
||||
if (schedule) {
|
||||
await this.scheduleRepo.update(schedule.id, data);
|
||||
|
||||
@@ -211,8 +211,7 @@ export class ClassroomsController {
|
||||
supervisor: String(row.getCell(7).value || '') || undefined,
|
||||
});
|
||||
});
|
||||
const departmentId = req.headers?.['x-campus-id'] ? parseInt(String(req.headers['x-campus-id']), 10) || undefined : undefined;
|
||||
const result = await this.service.batchImport(rows, departmentId);
|
||||
const result = await this.service.batchImport(rows);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Repository, Not } from 'typeorm';
|
||||
import { Classroom } from '../entities/classroom.entity';
|
||||
import { ClassroomRental } from '../entities/classroom-rental.entity';
|
||||
import { ClassSchedule } from '../entities/class-schedule.entity';
|
||||
import { CampusScope } from '../common/campus-scope';
|
||||
import { CreateClassroomDto, UpdateClassroomDto } from './dto/classroom.dto';
|
||||
|
||||
@Injectable()
|
||||
@@ -14,7 +13,6 @@ export class ClassroomsService {
|
||||
@InjectRepository(Classroom) private repo: Repository<Classroom>,
|
||||
@InjectRepository(ClassroomRental) private rentalRepo: Repository<ClassroomRental>,
|
||||
@InjectRepository(ClassSchedule) private scheduleRepo: Repository<ClassSchedule>,
|
||||
private readonly scope: CampusScope,
|
||||
) {}
|
||||
|
||||
async findAll(query?: { building?: string; roomType?: string; includeArchived?: boolean }) {
|
||||
@@ -22,7 +20,7 @@ export class ClassroomsService {
|
||||
if (query?.building) where.building = query.building;
|
||||
if (query?.roomType) where.roomType = query.roomType;
|
||||
if (!query?.includeArchived) where.status = Not('archived');
|
||||
const list = await this.repo.find({ where: await this.scope.filter(where), order: { building: 'ASC', name: 'ASC' } });
|
||||
const list = await this.repo.find({ where, order: { building: 'ASC', name: 'ASC' } });
|
||||
const usageMap = await this.getCurrentUsageForClassrooms(list.map((c) => c.id));
|
||||
return list.map((c) => ({ ...c, currentUsage: usageMap.get(c.id) ?? null }));
|
||||
}
|
||||
@@ -37,9 +35,7 @@ export class ClassroomsService {
|
||||
async create(dto: CreateClassroomDto) {
|
||||
const exists = await this.repo.findOne({ where: { name: dto.name } });
|
||||
if (exists) throw new BadRequestException(`教室 ${dto.name} 已存在`);
|
||||
const entity = this.repo.create(dto);
|
||||
if (dto.departmentId) entity.departmentId = dto.departmentId;
|
||||
return this.repo.save(entity);
|
||||
return this.repo.save(this.repo.create(dto));
|
||||
}
|
||||
|
||||
async update(id: number, dto: UpdateClassroomDto) {
|
||||
@@ -136,7 +132,6 @@ export class ClassroomsService {
|
||||
roomType?: string;
|
||||
courseType?: string;
|
||||
}[],
|
||||
departmentId?: number,
|
||||
) {
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
@@ -145,7 +140,7 @@ export class ClassroomsService {
|
||||
if (!row.name?.trim()) { skipped++; continue; }
|
||||
const exists = await this.repo.findOne({ where: { name: row.name.trim() } });
|
||||
if (exists) { errors.push(`教室 ${row.name} 已存在`); skipped++; continue; }
|
||||
await this.repo.save(this.repo.create({ ...row, capacity: row.capacity || 30, departmentId: departmentId ?? undefined }));
|
||||
await this.repo.save(this.repo.create({ ...row, capacity: row.capacity || 30 }));
|
||||
imported++;
|
||||
}
|
||||
return { message: `成功导入 ${imported} 间教室,跳过 ${skipped} 间`, imported, skipped, errors: errors.length > 0 ? errors : undefined };
|
||||
|
||||
@@ -33,9 +33,6 @@ export class CreateClassroomDto {
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
departmentId?: number;
|
||||
}
|
||||
|
||||
export class UpdateClassroomDto {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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())];
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CampusScope } from './campus-scope';
|
||||
import { CampusScopeMiddleware } from './campus-scope.middleware';
|
||||
import { DepartmentsModule } from '../departments/departments.module';
|
||||
|
||||
@Module({
|
||||
imports: [DepartmentsModule],
|
||||
providers: [CampusScope, CampusScopeMiddleware],
|
||||
exports: [CampusScope, CampusScopeMiddleware, DepartmentsModule],
|
||||
})
|
||||
@Module({})
|
||||
export class CommonModule {}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Class } from '../entities/class.entity';
|
||||
import { Deposit } from '../entities/deposit.entity';
|
||||
import { ClassroomRental } from '../entities/classroom-rental.entity';
|
||||
import { ClassTeacher } from '../entities/class-teacher.entity';
|
||||
import { CampusScope } from '../common/campus-scope';
|
||||
|
||||
|
||||
@Injectable()
|
||||
export class DashboardService {
|
||||
@@ -31,23 +31,20 @@ export class DashboardService {
|
||||
@InjectRepository(Deposit) private depositRepo: Repository<Deposit>,
|
||||
@InjectRepository(ClassroomRental) private rentalRepo: Repository<ClassroomRental>,
|
||||
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
|
||||
private readonly scope: CampusScope,
|
||||
) {}
|
||||
|
||||
async getStats() {
|
||||
const today = new Date();
|
||||
const todayStr = today.toISOString().slice(0, 10);
|
||||
const currentMonth = todayStr.slice(0, 7); // YYYY-MM
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
|
||||
const totalRooms = await this.roomRepo.count({ where: await this.scope.filter({ status: Not('archived') }) });
|
||||
const totalStudents = await this.studentRepo.count({ where: await this.scope.filter({ status: 'active' }) });
|
||||
const occupiedBeds = await this.occRepo.count({ where: await this.scope.filter({ checkOutDate: IsNull() }) });
|
||||
const totalRooms = await this.roomRepo.count({ where: { status: Not('archived') } });
|
||||
const totalStudents = await this.studentRepo.count({ where: { status: 'active' } });
|
||||
const occupiedBeds = await this.occRepo.count({ where: { checkOutDate: IsNull() } });
|
||||
const capQb = this.roomRepo
|
||||
.createQueryBuilder('r')
|
||||
.select('SUM(r.capacity)', 'total')
|
||||
.where('r.status != :archived', { archived: 'archived' });
|
||||
if (scopeIds) capQb.andWhere('r.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
const totalCapacity = await capQb.getRawOne();
|
||||
const cap = totalCapacity?.total || 0;
|
||||
const occupancyRate = cap > 0 ? ((occupiedBeds / cap) * 100).toFixed(1) : 0;
|
||||
@@ -58,11 +55,10 @@ export class DashboardService {
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.addSelect('SUM(b.totalAmount)', 'total')
|
||||
.groupBy('b.status');
|
||||
if (scopeIds) billStatsQb.andWhere('b.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
const billStats = await billStatsQb.getRawMany();
|
||||
|
||||
// New fields
|
||||
const classroomCount = await this.classroomRepo.count({ where: await this.scope.filter({}) });
|
||||
const classroomCount = await this.classroomRepo.count({ where: {} });
|
||||
|
||||
const occQb = this.scheduleRepo
|
||||
.createQueryBuilder('s')
|
||||
@@ -70,7 +66,6 @@ export class DashboardService {
|
||||
.where('s.status = :active', { active: 'active' })
|
||||
.andWhere('s.startDate <= :today', { today: todayStr })
|
||||
.andWhere('s.endDate >= :today', { today: todayStr });
|
||||
if (scopeIds) occQb.andWhere('s.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
const occResult = await occQb.getRawOne();
|
||||
const occupiedClassrooms = parseInt(occResult?.cnt || '0', 10);
|
||||
const classroomOccupancyRate = classroomCount > 0
|
||||
@@ -83,7 +78,6 @@ export class DashboardService {
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('a.attendanceDate = :today', { today: todayStr })
|
||||
.groupBy('a.status');
|
||||
if (scopeIds) attTodayQb.andWhere('a.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
const attTodayStats = await attTodayQb.getRawMany();
|
||||
const todayTotal = attTodayStats.reduce((sum, r) => sum + parseInt(r.count, 10), 0);
|
||||
const todayPresent = attTodayStats
|
||||
@@ -99,7 +93,6 @@ export class DashboardService {
|
||||
.where('b.status = :paid', { paid: 'paid' })
|
||||
.andWhere('b.periodStart >= :start', { start: `${currentMonth}-01` })
|
||||
.andWhere('b.periodStart < :end', { end: this.nextMonth(currentMonth) });
|
||||
if (scopeIds) incomeQb.andWhere('b.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
const incomeResult = await incomeQb.getRawOne();
|
||||
const monthlyIncome = parseFloat(incomeResult?.total || '0');
|
||||
|
||||
@@ -107,9 +100,8 @@ export class DashboardService {
|
||||
const incomeTrend = await this.getIncomeTrend(currentMonth);
|
||||
|
||||
// --- New stats ---
|
||||
const classCount = await this.classRepo.count({ where: await this.scope.filter({}) });
|
||||
const classCount = await this.classRepo.count({ where: {} });
|
||||
|
||||
// classTeacherRepo does not have departmentId — skip scope filtering
|
||||
const teacherResult = await this.classTeacherRepo
|
||||
.createQueryBuilder('ct')
|
||||
.select('COUNT(DISTINCT ct.userId)', 'cnt')
|
||||
@@ -121,11 +113,10 @@ export class DashboardService {
|
||||
.select('SUM(d.amount)', 'total')
|
||||
.where('d.status = :paid', { paid: 'paid' })
|
||||
.andWhere('d.refundStatus IS NULL');
|
||||
if (scopeIds) pendingQb.andWhere('d.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
const pendingResult = await pendingQb.getRawOne();
|
||||
const pendingDeposits = parseFloat(pendingResult?.total || '0');
|
||||
|
||||
const activeRentals = await this.rentalRepo.count({ where: await this.scope.filter({ endDate: MoreThanOrEqual(todayStr) }) });
|
||||
const activeRentals = await this.rentalRepo.count({ where: { endDate: MoreThanOrEqual(todayStr) } });
|
||||
|
||||
const occByBldQb = this.occRepo
|
||||
.createQueryBuilder('o')
|
||||
@@ -133,7 +124,6 @@ export class DashboardService {
|
||||
.select('r.building', 'building')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('o.checkOutDate IS NULL');
|
||||
if (scopeIds) occByBldQb.andWhere('r.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
const occupancyByBuilding = await occByBldQb.groupBy('r.building').getRawMany();
|
||||
|
||||
const attendanceByStatus = attTodayStats.reduce((acc, r) => {
|
||||
@@ -147,7 +137,6 @@ export class DashboardService {
|
||||
.addSelect('SUM(e.amount)', 'total')
|
||||
.where('e.periodStart >= :start', { start: `${currentMonth}-01` })
|
||||
.andWhere('e.periodEnd <= :end', { end: this.nextMonth(currentMonth) });
|
||||
if (scopeIds) expByTypeQb.andWhere('e.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
const expenseByType = await expByTypeQb.groupBy('e.expenseType').getRawMany();
|
||||
|
||||
return {
|
||||
@@ -238,7 +227,6 @@ export class DashboardService {
|
||||
|
||||
// 甘特图数据:每个宿舍的入住时间线
|
||||
async getGanttData(query?: { periodStart?: string; periodEnd?: string; building?: string }) {
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
const qb = this.occRepo
|
||||
.createQueryBuilder('o')
|
||||
.leftJoinAndSelect('o.student', 'student')
|
||||
@@ -247,7 +235,6 @@ export class DashboardService {
|
||||
.orderBy('room.roomNumber', 'ASC')
|
||||
.addOrderBy('o.checkInDate', 'ASC');
|
||||
|
||||
if (scopeIds) qb.andWhere('room.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
|
||||
if (query?.building) {
|
||||
qb.andWhere('room.building = :building', { building: query.building });
|
||||
@@ -283,13 +270,11 @@ export class DashboardService {
|
||||
}
|
||||
// 费用统计
|
||||
async getExpenseStats(periodStart?: string, periodEnd?: string) {
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
const qb = this.expRepo
|
||||
.createQueryBuilder('e')
|
||||
.select('e.expenseType', 'type')
|
||||
.addSelect('SUM(e.amount)', 'total')
|
||||
.groupBy('e.expenseType');
|
||||
if (scopeIds) qb.andWhere('e.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
if (periodStart) qb.andWhere('e.periodStart >= :ps', { ps: periodStart });
|
||||
if (periodEnd) qb.andWhere('e.periodEnd <= :pe', { pe: periodEnd });
|
||||
return qb.getRawMany();
|
||||
@@ -297,7 +282,6 @@ export class DashboardService {
|
||||
|
||||
// 各宿舍费用排行
|
||||
async getRoomExpenseRanking(periodStart?: string, periodEnd?: string) {
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
const qb = this.expRepo
|
||||
.createQueryBuilder('e')
|
||||
.leftJoin('e.room', 'room')
|
||||
@@ -307,7 +291,6 @@ export class DashboardService {
|
||||
.groupBy('e.roomId')
|
||||
.orderBy('total', 'DESC')
|
||||
.limit(20);
|
||||
if (scopeIds) qb.andWhere('e.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
if (periodStart) qb.andWhere('e.periodStart >= :ps', { ps: periodStart });
|
||||
if (periodEnd) qb.andWhere('e.periodEnd <= :pe', { pe: periodEnd });
|
||||
return qb.getRawMany();
|
||||
@@ -315,7 +298,6 @@ export class DashboardService {
|
||||
|
||||
// 班级考勤排行
|
||||
async getClassAttendanceRanking() {
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
const qb = this.attendanceRepo
|
||||
.createQueryBuilder('a')
|
||||
.leftJoin('a.class', 'class')
|
||||
@@ -324,7 +306,6 @@ export class DashboardService {
|
||||
.addSelect('a.status', 'status')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.groupBy('class.id').addGroupBy('class.name').addGroupBy('a.status');
|
||||
if (scopeIds) qb.andWhere('a.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
const raw = await qb.getRawMany();
|
||||
|
||||
const classMap = new Map<number, { className: string; present: number; total: number }>();
|
||||
@@ -345,9 +326,8 @@ export class DashboardService {
|
||||
}
|
||||
|
||||
async getClassroomOccupancy() {
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
const classrooms = await this.classroomRepo.find({
|
||||
where: await this.scope.filter({ status: Not('archived') }),
|
||||
where: { status: Not('archived') },
|
||||
order: { building: 'ASC', name: 'ASC' },
|
||||
});
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
@@ -359,7 +339,6 @@ export class DashboardService {
|
||||
.andWhere('s.scheduleType = :type', { type: 'INTERNAL' })
|
||||
.andWhere('s.startDate <= :today AND s.endDate >= :today', { today })
|
||||
.groupBy('s.classroomId');
|
||||
if (scopeIds) schedQb.andWhere('s.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
const schedules = await schedQb.getRawMany();
|
||||
const rentalQb = this.rentalRepo
|
||||
.createQueryBuilder('r')
|
||||
@@ -368,7 +347,6 @@ export class DashboardService {
|
||||
.where('r.status != :cancelled', { cancelled: 'cancelled' })
|
||||
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today })
|
||||
.groupBy('r.classroomId');
|
||||
if (scopeIds) rentalQb.andWhere('r.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
const rentals = await rentalQb.getRawMany();
|
||||
const sMap: Record<number, number> = {};
|
||||
const rMap: Record<number, number> = {};
|
||||
@@ -385,9 +363,8 @@ export class DashboardService {
|
||||
}
|
||||
|
||||
async getClassroomUtilizationStats() {
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
const totalClassrooms = await this.classroomRepo.count({
|
||||
where: await this.scope.filter({ status: Not('archived') }),
|
||||
where: { status: Not('archived') },
|
||||
});
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
@@ -399,7 +376,6 @@ export class DashboardService {
|
||||
.where('s.status = :active', { active: 'active' })
|
||||
.andWhere('s.scheduleType = :type', { type: 'INTERNAL' })
|
||||
.andWhere('s.startDate <= :today AND s.endDate >= :today', { today });
|
||||
if (scopeIds) schedQb.andWhere('s.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
const schedResult = await schedQb.getRawOne();
|
||||
|
||||
// Count classrooms with active rentals today
|
||||
@@ -408,7 +384,6 @@ export class DashboardService {
|
||||
.select('COUNT(DISTINCT r.classroomId)', 'cnt')
|
||||
.where('r.status != :cancelled', { cancelled: 'cancelled' })
|
||||
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today });
|
||||
if (scopeIds) rentalQb.andWhere('r.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
const rentalResult = await rentalQb.getRawOne();
|
||||
|
||||
// Combine: use Set merge of both
|
||||
@@ -419,7 +394,6 @@ export class DashboardService {
|
||||
.andWhere('s.scheduleType = :type', { type: 'INTERNAL' })
|
||||
.andWhere('s.startDate <= :today AND s.endDate >= :today', { today })
|
||||
.groupBy('s.classroomId');
|
||||
if (scopeIds) combinedQb.andWhere('s.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
const schedIds = await combinedQb.getRawMany();
|
||||
|
||||
const combinedRentalQb = this.rentalRepo
|
||||
@@ -428,7 +402,6 @@ export class DashboardService {
|
||||
.where('r.status != :cancelled', { cancelled: 'cancelled' })
|
||||
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today })
|
||||
.groupBy('r.classroomId');
|
||||
if (scopeIds) combinedRentalQb.andWhere('r.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
const rentalIds = await combinedRentalQb.getRawMany();
|
||||
|
||||
const allInUseIds = new Set([
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { Repository } from 'typeorm';
|
||||
import { Deposit } from '../entities/deposit.entity';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { DepositInstallment } from '../entities/deposit-installment.entity';
|
||||
import { CampusScope } from '../common/campus-scope';
|
||||
|
||||
import { CreateDepositDto, RefundDepositDto, CreateDepositWithInstallmentsDto } from './dto/deposit.dto';
|
||||
|
||||
@Injectable()
|
||||
@@ -16,17 +16,14 @@ export class DepositsService {
|
||||
private installmentRepo: Repository<DepositInstallment>,
|
||||
@InjectRepository(Student)
|
||||
private studentRepo: Repository<Student>,
|
||||
private readonly scope: CampusScope,
|
||||
) {}
|
||||
|
||||
async findAll(query?: { studentId?: number; status?: string }) {
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
const qb = this.repo
|
||||
.createQueryBuilder('d')
|
||||
.leftJoinAndSelect('d.student', 'student')
|
||||
.leftJoinAndSelect('d.installments', 'installments')
|
||||
.orderBy('d.createdAt', 'DESC');
|
||||
if (scopeIds) qb.andWhere('d.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
if (query?.studentId) qb.andWhere('d.studentId = :studentId', { studentId: query.studentId });
|
||||
if (query?.status) qb.andWhere('d.status = :status', { status: query.status });
|
||||
return qb.getMany();
|
||||
@@ -49,7 +46,6 @@ export class DepositsService {
|
||||
status: 'paid',
|
||||
recordedBy: userId,
|
||||
});
|
||||
deposit.departmentId = student.departmentId;
|
||||
|
||||
if (dto instanceof CreateDepositWithInstallmentsDto && dto.installments?.length) {
|
||||
deposit.installments = dto.installments.map((i) => {
|
||||
@@ -58,7 +54,6 @@ export class DepositsService {
|
||||
dueDate: i.dueDate,
|
||||
status: 'pending',
|
||||
});
|
||||
inst.departmentId = student.departmentId;
|
||||
return inst;
|
||||
});
|
||||
}
|
||||
@@ -174,11 +169,10 @@ export class DepositsService {
|
||||
return this.repo.save(deposit);
|
||||
}
|
||||
async findPendingRefunds() {
|
||||
const baseWhere = await this.scope.filter({});
|
||||
return this.repo.find({
|
||||
where: [
|
||||
{ ...baseWhere, refundStatus: 'pending' },
|
||||
{ ...baseWhere, refundStatus: 'head_teacher_approved' },
|
||||
{ refundStatus: 'pending' },
|
||||
{ refundStatus: 'head_teacher_approved' },
|
||||
],
|
||||
relations: ['student', 'installments'],
|
||||
order: { refundRequestedAt: 'DESC' },
|
||||
@@ -193,13 +187,11 @@ export class DepositsService {
|
||||
}
|
||||
|
||||
async getStats() {
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
const qb = this.repo
|
||||
.createQueryBuilder('d')
|
||||
.select('d.status', 'status')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.addSelect('SUM(d.amount)', 'totalAmount');
|
||||
if (scopeIds) qb.andWhere('d.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
qb.groupBy('d.status');
|
||||
return qb.getRawMany();
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Student } from './student.entity';
|
||||
import { Department } from './department.entity';
|
||||
|
||||
@Entity('archive_attachments')
|
||||
export class ArchiveAttachment {
|
||||
@@ -37,12 +36,6 @@ export class ArchiveAttachment {
|
||||
@Column({ name: 'mime_type', length: 100, nullable: true })
|
||||
mimeType: string;
|
||||
|
||||
@Column({ name: 'department_id', type: 'integer', nullable: true })
|
||||
departmentId: number;
|
||||
|
||||
@ManyToOne(() => Department, { nullable: true })
|
||||
@JoinColumn({ name: 'department_id' })
|
||||
department: Department;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
} from 'typeorm';
|
||||
import { Student } from './student.entity';
|
||||
import { Class } from './class.entity';
|
||||
import { Department } from './department.entity';
|
||||
|
||||
@Entity('attendance_records')
|
||||
@Index(['classId', 'attendanceDate'])
|
||||
@@ -54,10 +53,4 @@ export class AttendanceRecord {
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
|
||||
@Column({ name: 'department_id', type: 'integer', nullable: true })
|
||||
departmentId: number;
|
||||
|
||||
@ManyToOne(() => Department, { nullable: true })
|
||||
@JoinColumn({ name: 'department_id' })
|
||||
department: Department;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from 'typeorm';
|
||||
import { Student } from './student.entity';
|
||||
import { BillItem } from './bill-item.entity';
|
||||
import { Department } from './department.entity';
|
||||
|
||||
@Entity('bills')
|
||||
export class Bill {
|
||||
@@ -47,10 +46,4 @@ export class Bill {
|
||||
@OneToMany(() => BillItem, (bi) => bi.bill)
|
||||
items: BillItem[];
|
||||
|
||||
@Column({ name: 'department_id', type: 'integer', nullable: true })
|
||||
departmentId: number;
|
||||
|
||||
@ManyToOne(() => Department, { nullable: true })
|
||||
@JoinColumn({ name: 'department_id' })
|
||||
department: Department;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
JoinColumn,
|
||||
Check,
|
||||
} from 'typeorm';
|
||||
import { Department } from './department.entity';
|
||||
|
||||
export enum ScheduleType {
|
||||
INTERNAL = 'INTERNAL',
|
||||
@@ -81,10 +80,4 @@ export class ClassSchedule {
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
|
||||
@Column({ name: 'department_id', type: 'integer', nullable: true })
|
||||
departmentId: number;
|
||||
|
||||
@ManyToOne(() => Department, { nullable: true })
|
||||
@JoinColumn({ name: 'department_id' })
|
||||
department: Department;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
} from 'typeorm';
|
||||
import { Classroom } from './classroom.entity';
|
||||
import { Tenant } from './tenant.entity';
|
||||
import { Department } from './department.entity';
|
||||
|
||||
@Entity('classroom_rentals')
|
||||
@Index(['classroomId', 'startDate', 'endDate'])
|
||||
@@ -66,10 +65,4 @@ export class ClassroomRental {
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
|
||||
@Column({ name: 'department_id', type: 'integer', nullable: true })
|
||||
departmentId: number;
|
||||
|
||||
@ManyToOne(() => Department, { nullable: true })
|
||||
@JoinColumn({ name: 'department_id' })
|
||||
department: Department;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { Department } from './department.entity';
|
||||
|
||||
@Entity('classrooms')
|
||||
export class Classroom {
|
||||
@@ -36,10 +35,4 @@ export class Classroom {
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@Column({ name: 'department_id', type: 'integer', nullable: true })
|
||||
departmentId: number;
|
||||
|
||||
@ManyToOne(() => Department, { nullable: true })
|
||||
@JoinColumn({ name: 'department_id' })
|
||||
department: Department;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Deposit } from './deposit.entity';
|
||||
import { Department } from './department.entity';
|
||||
|
||||
@Entity('deposit_installments')
|
||||
export class DepositInstallment {
|
||||
@@ -36,10 +35,4 @@ export class DepositInstallment {
|
||||
@JoinColumn({ name: 'deposit_id' })
|
||||
deposit: Deposit;
|
||||
|
||||
@Column({ name: 'department_id', type: 'integer', nullable: true })
|
||||
departmentId: number;
|
||||
|
||||
@ManyToOne(() => Department, { nullable: true })
|
||||
@JoinColumn({ name: 'department_id' })
|
||||
department: Department;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from 'typeorm';
|
||||
import { Student } from './student.entity';
|
||||
import { DepositInstallment } from './deposit-installment.entity';
|
||||
import { Department } from './department.entity';
|
||||
|
||||
@Entity('deposits')
|
||||
export class Deposit {
|
||||
@@ -72,10 +71,4 @@ export class Deposit {
|
||||
@JoinColumn({ name: 'student_id' })
|
||||
student: Student;
|
||||
|
||||
@Column({ name: 'department_id', type: 'integer', nullable: true })
|
||||
departmentId: number;
|
||||
|
||||
@ManyToOne(() => Department, { nullable: true })
|
||||
@JoinColumn({ name: 'department_id' })
|
||||
department: Department;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from 'typeorm';
|
||||
import { Student } from './student.entity';
|
||||
import { StudentEnrollment } from './student-enrollment.entity';
|
||||
import { Department } from './department.entity';
|
||||
|
||||
@Entity('exam_scores')
|
||||
export class ExamScore {
|
||||
@@ -51,12 +50,6 @@ export class ExamScore {
|
||||
@Column({ name: 'exam_date', type: 'date', nullable: true })
|
||||
examDate: string;
|
||||
|
||||
@Column({ name: 'department_id', type: 'integer', nullable: true })
|
||||
departmentId: number;
|
||||
|
||||
@ManyToOne(() => Department, { nullable: true })
|
||||
@JoinColumn({ name: 'department_id' })
|
||||
department: Department;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@@ -26,8 +26,6 @@ export { SyncLog } from './sync-log.entity';
|
||||
export { SyncState } from './sync-state.entity';
|
||||
export { ExpenseType } from './expense-type.entity';
|
||||
export { Notification, NotificationType } from './notification.entity';
|
||||
export { Department, DepartmentType } from './department.entity';
|
||||
export { UserDepartment } from './user-department.entity';
|
||||
export { StudentProfile } from './student-profile.entity';
|
||||
export { StudentEnrollment } from './student-enrollment.entity';
|
||||
export { ExamScore } from './exam-score.entity';
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Student } from './student.entity';
|
||||
import { Department } from './department.entity';
|
||||
|
||||
@Entity('learning_records')
|
||||
export class LearningRecord {
|
||||
@@ -37,12 +36,6 @@ export class LearningRecord {
|
||||
@Column({ name: 'next_step', type: 'text', nullable: true })
|
||||
nextStep: string;
|
||||
|
||||
@Column({ name: 'department_id', type: 'integer', nullable: true })
|
||||
departmentId: number;
|
||||
|
||||
@ManyToOne(() => Department, { nullable: true })
|
||||
@JoinColumn({ name: 'department_id' })
|
||||
department: Department;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Room } from './room.entity';
|
||||
import { Tenant } from './tenant.entity';
|
||||
import { Bed } from './bed.entity';
|
||||
import { Locker } from './locker.entity';
|
||||
import { Department } from './department.entity';
|
||||
|
||||
@Entity('occupancies')
|
||||
export class Occupancy {
|
||||
@@ -77,10 +76,4 @@ export class Occupancy {
|
||||
@JoinColumn({ name: 'room_id' })
|
||||
room: Room;
|
||||
|
||||
@Column({ name: 'department_id', type: 'integer', nullable: true })
|
||||
departmentId: number;
|
||||
|
||||
@ManyToOne(() => Department, { nullable: true })
|
||||
@JoinColumn({ name: 'department_id' })
|
||||
department: Department;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Student } from './student.entity';
|
||||
import { Department } from './department.entity';
|
||||
|
||||
@Entity('personal_expenses')
|
||||
export class PersonalExpense {
|
||||
@@ -42,10 +41,4 @@ export class PersonalExpense {
|
||||
@JoinColumn({ name: 'student_id' })
|
||||
student: Student;
|
||||
|
||||
@Column({ name: 'department_id', type: 'integer', nullable: true })
|
||||
departmentId: number;
|
||||
|
||||
@ManyToOne(() => Department, { nullable: true })
|
||||
@JoinColumn({ name: 'department_id' })
|
||||
department: Department;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Student } from './student.entity';
|
||||
import { Department } from './department.entity';
|
||||
|
||||
@Entity('result_archives')
|
||||
export class ResultArchive {
|
||||
@@ -37,12 +36,6 @@ export class ResultArchive {
|
||||
@Column({ name: 'admitted_major', length: 100, nullable: true })
|
||||
admittedMajor: string;
|
||||
|
||||
@Column({ name: 'department_id', type: 'integer', nullable: true })
|
||||
departmentId: number;
|
||||
|
||||
@ManyToOne(() => Department, { nullable: true })
|
||||
@JoinColumn({ name: 'department_id' })
|
||||
department: Department;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Room } from './room.entity';
|
||||
import { Department } from './department.entity';
|
||||
|
||||
@Entity('room_expenses')
|
||||
export class RoomExpense {
|
||||
@@ -42,10 +41,4 @@ export class RoomExpense {
|
||||
@JoinColumn({ name: 'room_id' })
|
||||
room: Room;
|
||||
|
||||
@Column({ name: 'department_id', type: 'integer', nullable: true })
|
||||
departmentId: number;
|
||||
|
||||
@ManyToOne(() => Department, { nullable: true })
|
||||
@JoinColumn({ name: 'department_id' })
|
||||
department: Department;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, OneToMany, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { Occupancy } from './occupancy.entity';
|
||||
import { RoomExpense } from './room-expense.entity';
|
||||
import { Department } from './department.entity';
|
||||
|
||||
@Entity('rooms')
|
||||
export class Room {
|
||||
@@ -44,10 +43,4 @@ export class Room {
|
||||
@OneToMany(() => RoomExpense, (e) => e.room)
|
||||
roomExpenses: RoomExpense[];
|
||||
|
||||
@Column({ name: 'department_id', type: 'integer', nullable: true })
|
||||
departmentId: number;
|
||||
|
||||
@ManyToOne(() => Department, { nullable: true })
|
||||
@JoinColumn({ name: 'department_id' })
|
||||
department: Department;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Student } from './student.entity';
|
||||
import { Department } from './department.entity';
|
||||
|
||||
@Entity('student_enrollments')
|
||||
export class StudentEnrollment {
|
||||
@@ -46,12 +45,6 @@ export class StudentEnrollment {
|
||||
@Column({ length: 20, default: 'active' })
|
||||
status: string;
|
||||
|
||||
@Column({ name: 'department_id', type: 'integer', nullable: true })
|
||||
departmentId: number;
|
||||
|
||||
@ManyToOne(() => Department, { nullable: true })
|
||||
@JoinColumn({ name: 'department_id' })
|
||||
department: Department;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Student } from './student.entity';
|
||||
import { Department } from './department.entity';
|
||||
|
||||
@Entity('student_profiles')
|
||||
export class StudentProfile {
|
||||
@@ -43,12 +42,6 @@ export class StudentProfile {
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes: string;
|
||||
|
||||
@Column({ name: 'department_id', type: 'integer', nullable: true })
|
||||
departmentId: number;
|
||||
|
||||
@ManyToOne(() => Department, { nullable: true })
|
||||
@JoinColumn({ name: 'department_id' })
|
||||
department: Department;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Student } from './student.entity';
|
||||
import { Department } from './department.entity';
|
||||
|
||||
@Entity('student_reports')
|
||||
export class StudentReport {
|
||||
@@ -34,12 +33,6 @@ export class StudentReport {
|
||||
@Column({ name: 'generated_at', type: 'datetime', nullable: true })
|
||||
generatedAt: Date;
|
||||
|
||||
@Column({ name: 'department_id', type: 'integer', nullable: true })
|
||||
departmentId: number;
|
||||
|
||||
@ManyToOne(() => Department, { nullable: true })
|
||||
@JoinColumn({ name: 'department_id' })
|
||||
department: Department;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@@ -14,7 +14,6 @@ import { PersonalExpense } from './personal-expense.entity';
|
||||
import { Bill } from './bill.entity';
|
||||
import { Tenant } from './tenant.entity';
|
||||
import { User } from './user.entity';
|
||||
import { Department } from './department.entity';
|
||||
|
||||
@Entity('students')
|
||||
export class Student {
|
||||
@@ -82,10 +81,4 @@ export class Student {
|
||||
@OneToMany(() => Bill, (b) => b.student)
|
||||
bills: Bill[];
|
||||
|
||||
@Column({ name: 'department_id', type: 'integer', nullable: true })
|
||||
departmentId: number;
|
||||
|
||||
@ManyToOne(() => Department, { nullable: true })
|
||||
@JoinColumn({ name: 'department_id' })
|
||||
department: Department;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
BatchRoomExpenseDto,
|
||||
} from './dto/expense.dto';
|
||||
import { RoomsService } from '../rooms/rooms.service';
|
||||
import { CampusScope } from '../common/campus-scope';
|
||||
|
||||
|
||||
@Injectable()
|
||||
export class ExpensesService {
|
||||
@@ -20,7 +20,6 @@ export class ExpensesService {
|
||||
@InjectRepository(PersonalExpense) private personalExpRepo: Repository<PersonalExpense>,
|
||||
@InjectRepository(Room) private roomRepo: Repository<Room>,
|
||||
@InjectRepository(Student) private studentRepo: Repository<Student>,
|
||||
private readonly scope: CampusScope,
|
||||
) {}
|
||||
|
||||
// 宿舍费用
|
||||
@@ -28,14 +27,10 @@ export class ExpensesService {
|
||||
const room = await this.roomRepo.findOne({ where: { id: dto.roomId } });
|
||||
if (!room) throw new NotFoundException('宿舍不存在');
|
||||
const entity = this.roomExpRepo.create({ ...dto, recordedBy: userId });
|
||||
entity.departmentId = room.departmentId;
|
||||
return this.roomExpRepo.save(entity);
|
||||
}
|
||||
|
||||
async batchCreateRoomExpenses(dto: BatchRoomExpenseDto, userId?: number) {
|
||||
const roomIds = [...new Set(dto.expenses.map((e) => e.roomId))];
|
||||
const rooms = await this.roomRepo.find({ where: { id: In(roomIds) } });
|
||||
const roomDeptMap = new Map(rooms.map((r) => [r.id, r.departmentId]));
|
||||
const entities = dto.expenses.map((e) => {
|
||||
const entity = this.roomExpRepo.create({
|
||||
roomId: e.roomId,
|
||||
@@ -46,19 +41,16 @@ export class ExpensesService {
|
||||
periodEnd: dto.periodEnd,
|
||||
recordedBy: userId,
|
||||
});
|
||||
entity.departmentId = roomDeptMap.get(e.roomId)!;
|
||||
return entity;
|
||||
});
|
||||
return this.roomExpRepo.save(entities);
|
||||
}
|
||||
|
||||
async findRoomExpenses(query?: { roomId?: number; periodStart?: string; periodEnd?: string }) {
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
const qb = this.roomExpRepo
|
||||
.createQueryBuilder('e')
|
||||
.leftJoinAndSelect('e.room', 'room')
|
||||
.orderBy('e.createdAt', 'DESC');
|
||||
if (scopeIds) qb.andWhere('e.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
if (query?.roomId) qb.andWhere('e.roomId = :roomId', { roomId: query.roomId });
|
||||
if (query?.periodStart) qb.andWhere('e.periodStart >= :ps', { ps: query.periodStart });
|
||||
if (query?.periodEnd) qb.andWhere('e.periodEnd <= :pe', { pe: query.periodEnd });
|
||||
@@ -94,14 +86,12 @@ export class ExpensesService {
|
||||
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
|
||||
if (!student) throw new NotFoundException('学生不存在');
|
||||
const entity = this.personalExpRepo.create({ ...dto, recordedBy: userId });
|
||||
entity.departmentId = student.departmentId;
|
||||
return this.personalExpRepo.save(entity);
|
||||
}
|
||||
|
||||
async findPersonalExpenses(query?: { studentId?: number }) {
|
||||
let where: Record<string, unknown> = {};
|
||||
const where: Record<string, unknown> = {};
|
||||
if (query?.studentId) where.studentId = query.studentId;
|
||||
where = await this.scope.filter(where);
|
||||
return this.personalExpRepo.find({
|
||||
where,
|
||||
relations: ['student'],
|
||||
|
||||
@@ -3,13 +3,11 @@
|
||||
*
|
||||
* 提供:
|
||||
* - OAuth2 access_token(新版 API + 缓存)
|
||||
* - BFS 遍历所有部门 + 用户(带限流)
|
||||
* - 用户同步(自动建 Student + StudentDingMapping)
|
||||
*/
|
||||
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Department } from '../entities/department.entity';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { StudentDingMapping } from '../entities/student-ding-mapping.entity';
|
||||
|
||||
@@ -20,18 +18,6 @@ interface DingTalkTokenResponse {
|
||||
expireIn: number;
|
||||
}
|
||||
|
||||
interface SubDeptIdListResponse {
|
||||
errcode: number;
|
||||
errmsg: string;
|
||||
result: { dept_id_list: number[] };
|
||||
}
|
||||
|
||||
interface DepartmentDetailResponse {
|
||||
errcode: number;
|
||||
errmsg: string;
|
||||
result: { dept_id: number; name: string; parent_id: number };
|
||||
}
|
||||
|
||||
interface DingTalkUserListResponse {
|
||||
errcode: number;
|
||||
errmsg: string;
|
||||
@@ -61,28 +47,6 @@ export interface DingTalkAttendanceResult {
|
||||
checkType: string;
|
||||
}
|
||||
|
||||
/** 钉钉部门树节点,供前端选择器使用 */
|
||||
export interface DingOrgTreeNode {
|
||||
id: number;
|
||||
name: string;
|
||||
parentId: number;
|
||||
children: DingOrgTreeNode[];
|
||||
}
|
||||
|
||||
/** 钉钉部门树节点(含用户),供同步用户选择器使用 */
|
||||
export interface DingOrgTreeNodeWithUsers {
|
||||
id: number;
|
||||
name: string;
|
||||
parentId: number;
|
||||
children: DingOrgTreeNodeWithUsers[];
|
||||
users: Array<{
|
||||
userid: string;
|
||||
name: string;
|
||||
mobile: string;
|
||||
deptIds: number[];
|
||||
}>;
|
||||
}
|
||||
|
||||
// ── 考勤排班 API 类型 ──
|
||||
|
||||
/** 班次卡段打卡时间 */
|
||||
@@ -182,8 +146,6 @@ export class DingTalkService {
|
||||
private static readonly MIN_INTERVAL = 1000 / DingTalkService.RATE_LIMIT;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Department)
|
||||
private readonly deptRepo: Repository<Department>,
|
||||
@InjectRepository(Student)
|
||||
private readonly studentRepo: Repository<Student>,
|
||||
@InjectRepository(StudentDingMapping)
|
||||
@@ -222,65 +184,6 @@ export class DingTalkService {
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Department BFS — 对齐 gongxue-dorm-sys getAllSubDepartmentIds
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
private async getAllDeptIds(token: string, rootDeptId = 1): Promise<number[]> {
|
||||
const ids: number[] = [];
|
||||
const queue: number[] = [rootDeptId];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const deptId = queue.shift()!;
|
||||
ids.push(deptId);
|
||||
|
||||
try {
|
||||
await this.rateLimit();
|
||||
const res = await fetch(
|
||||
`https://oapi.dingtalk.com/topapi/v2/department/listsubid?access_token=${token}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dept_id: deptId }),
|
||||
},
|
||||
);
|
||||
const body: SubDeptIdListResponse = await res.json();
|
||||
if (body.errcode === 0 && body.result?.dept_id_list) {
|
||||
queue.push(...body.result.dept_id_list);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error(`获取部门 ${deptId} 子部门失败: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Department detail
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
private async getDeptDetail(
|
||||
token: string,
|
||||
deptId: number,
|
||||
): Promise<{ dept_id: number; name: string; parent_id: number } | null> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://oapi.dingtalk.com/topapi/v2/department/get?access_token=${token}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dept_id: deptId, language: 'zh_CN' }),
|
||||
},
|
||||
);
|
||||
const body: DepartmentDetailResponse = await res.json();
|
||||
return body.errcode === 0 ? body.result : null;
|
||||
} catch (e) {
|
||||
this.logger.error(`获取部门 ${deptId} 详情失败: ${(e as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Users by department — 对齐 gongxue-dorm-sys getUsersByDepartment
|
||||
// ═══════════════════════════════════════════
|
||||
@@ -334,191 +237,39 @@ export class DingTalkService {
|
||||
const t0 = Date.now();
|
||||
const token = await this.getAccessToken();
|
||||
|
||||
// ── Step 1: BFS traverse all departments ──
|
||||
this.logger.log('开始 BFS 遍历钉钉部门...');
|
||||
const deptIds = await this.getAllDeptIds(token, rootDeptId);
|
||||
this.logger.log(`共发现 ${deptIds.length} 个部门`);
|
||||
|
||||
// ── Step 2: Sync departments ──
|
||||
let deptCount = 0;
|
||||
for (let i = 0; i < deptIds.length; i++) {
|
||||
const deptId = deptIds[i];
|
||||
if (i > 0) await this.delay(i);
|
||||
|
||||
const detail = await this.getDeptDetail(token, deptId);
|
||||
if (!detail) continue;
|
||||
|
||||
const sourceId = String(detail.dept_id);
|
||||
let dept = await this.deptRepo.findOne({ where: { source: 'dingtalk', sourceId } });
|
||||
if (dept) {
|
||||
dept.name = detail.name;
|
||||
if (detail.parent_id) dept.parentSourceId = String(detail.parent_id);
|
||||
} else {
|
||||
dept = this.deptRepo.create({
|
||||
name: detail.name,
|
||||
source: 'dingtalk',
|
||||
sourceId,
|
||||
type: 'department',
|
||||
} as Department);
|
||||
if (detail.parent_id) dept.parentSourceId = String(detail.parent_id);
|
||||
deptCount++;
|
||||
}
|
||||
await this.deptRepo.save(dept);
|
||||
}
|
||||
|
||||
// Set parent relationships
|
||||
const syncedDepts = await this.deptRepo.find({ where: { source: 'dingtalk' } });
|
||||
const idMap = new Map(syncedDepts.map((d) => [d.sourceId, d.id]));
|
||||
for (const dept of syncedDepts) {
|
||||
if (dept.parentSourceId && idMap.has(dept.parentSourceId)) {
|
||||
dept.parentId = idMap.get(dept.parentSourceId)!;
|
||||
} else if (dept.parentSourceId === '1' || dept.parentSourceId === '0') {
|
||||
dept.parentId = undefined as any;
|
||||
}
|
||||
}
|
||||
await this.deptRepo.save(syncedDepts);
|
||||
|
||||
|
||||
// ── Step 3: Sync users per department ──
|
||||
// ── Sync users from root department ──
|
||||
let userCount = 0;
|
||||
const seenUserIds = new Set<string>();
|
||||
|
||||
for (let i = 0; i < deptIds.length; i++) {
|
||||
const deptId = deptIds[i];
|
||||
const dingUsers = await this.getDeptUsers(token, deptId);
|
||||
const dingUsers = await this.getDeptUsers(token, rootDeptId);
|
||||
for (const du of dingUsers) {
|
||||
if (seenUserIds.has(du.userid)) continue;
|
||||
seenUserIds.add(du.userid);
|
||||
|
||||
for (const du of dingUsers) {
|
||||
if (seenUserIds.has(du.userid)) continue;
|
||||
seenUserIds.add(du.userid);
|
||||
|
||||
await this.syncOneUser(du);
|
||||
userCount++;
|
||||
}
|
||||
await this.syncOneUser(du);
|
||||
userCount++;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`钉钉同步完成: ${deptCount} 个新部门, ${userCount} 个用户, API 请求 ${this.apiRequestCount} 次, 耗时 ${Date.now() - t0}ms`,
|
||||
`钉钉同步完成: ${userCount} 个用户, API 请求 ${this.apiRequestCount} 次, 耗时 ${Date.now() - t0}ms`,
|
||||
);
|
||||
return { deptCount, userCount };
|
||||
return { deptCount: 0, userCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取钉钉组织部门树(只含部门,不含用户),供前端选择同步起点。
|
||||
* 返回从指定 rootDeptId 开始的树;默认根部门 1。
|
||||
* 获取钉钉组织部门树(只含部门,不含用户)。
|
||||
* ponytail: Department entity removed; returns empty array.
|
||||
*/
|
||||
async fetchOrgTree(rootDeptId = 1): Promise<DingOrgTreeNode[]> {
|
||||
if (!this.configured) {
|
||||
throw new ServiceUnavailableException('钉钉未配置');
|
||||
}
|
||||
const token = await this.getAccessToken();
|
||||
const deptIds = await this.getAllDeptIds(token, rootDeptId);
|
||||
|
||||
// 拉每个部门详情
|
||||
const nodes: DingOrgTreeNode[] = [];
|
||||
for (let i = 0; i < deptIds.length; i++) {
|
||||
if (i > 0) await this.delay(i);
|
||||
const detail = await this.getDeptDetail(token, deptIds[i]);
|
||||
if (detail) {
|
||||
nodes.push({
|
||||
id: detail.dept_id,
|
||||
name: detail.name,
|
||||
parentId: detail.parent_id,
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 组装成树
|
||||
const map = new Map<number, DingOrgTreeNode>();
|
||||
nodes.forEach((n) => map.set(n.id, n));
|
||||
const roots: DingOrgTreeNode[] = [];
|
||||
for (const node of nodes) {
|
||||
const parent = map.get(node.parentId);
|
||||
if (parent && node.id !== rootDeptId) {
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
async fetchOrgTree(_rootDeptId = 1): Promise<[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取钉钉组织部门树(含用户),供前端同步用户选择器使用。
|
||||
* 返回从指定 rootDeptId 开始的树,每个部门节点含 users 数组。
|
||||
* 获取钉钉组织部门树(含用户)。
|
||||
* ponytail: Department entity removed; returns empty array.
|
||||
*/
|
||||
async fetchOrgTreeWithUsers(rootDeptId = 1): Promise<DingOrgTreeNodeWithUsers[]> {
|
||||
if (!this.configured) {
|
||||
throw new ServiceUnavailableException('钉钉未配置');
|
||||
}
|
||||
const token = await this.getAccessToken();
|
||||
|
||||
const deptIds = await this.getAllDeptIds(token, rootDeptId);
|
||||
|
||||
// 拉每个部门详情
|
||||
const nodes: DingOrgTreeNodeWithUsers[] = [];
|
||||
// Before dedup: collect deptIds per user
|
||||
const userDeptMap = new Map<string, number[]>();
|
||||
|
||||
for (let i = 0; i < deptIds.length; i++) {
|
||||
if (i > 0) await this.delay(i);
|
||||
const detail = await this.getDeptDetail(token, deptIds[i]);
|
||||
if (!detail) continue;
|
||||
|
||||
// 拉该部门下的用户
|
||||
const dingUsers = await this.getDeptUsers(token, deptIds[i]);
|
||||
this.logger.log(`[dingtalk] dept ${deptIds[i]} (${detail.name}): ${dingUsers.length} users`);
|
||||
|
||||
nodes.push({
|
||||
id: detail.dept_id,
|
||||
name: detail.name,
|
||||
parentId: detail.parent_id,
|
||||
children: [],
|
||||
users: dingUsers.map((u) => ({
|
||||
userid: u.userid,
|
||||
name: u.name,
|
||||
mobile: u.mobile,
|
||||
deptIds: [],
|
||||
})),
|
||||
});
|
||||
|
||||
// Record which departments each user belongs to
|
||||
for (const u of dingUsers) {
|
||||
if (!userDeptMap.has(u.userid)) {
|
||||
userDeptMap.set(u.userid, []);
|
||||
}
|
||||
userDeptMap.get(u.userid)!.push(detail.dept_id);
|
||||
}
|
||||
}
|
||||
|
||||
// 全局去重:同一个 dingUserId 可能在多个部门出现
|
||||
const seenUserIds = new Set<string>();
|
||||
for (const node of nodes) {
|
||||
node.users = node.users
|
||||
.filter((u) => {
|
||||
if (seenUserIds.has(u.userid)) return false;
|
||||
seenUserIds.add(u.userid);
|
||||
return true;
|
||||
})
|
||||
.map((u) => ({
|
||||
...u,
|
||||
deptIds: userDeptMap.get(u.userid) || [],
|
||||
}));
|
||||
}
|
||||
|
||||
// 组装成树(父节点可能已被过滤,缺失的父节点 → 节点提升为根)
|
||||
const map = new Map<number, DingOrgTreeNodeWithUsers>();
|
||||
nodes.forEach((n) => map.set(n.id, n));
|
||||
const roots: DingOrgTreeNodeWithUsers[] = [];
|
||||
for (const node of nodes) {
|
||||
const parent = map.get(node.parentId);
|
||||
if (parent && node.id !== rootDeptId) {
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
async fetchOrgTreeWithUsers(_rootDeptId = 1): Promise<[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Department, User, Student, StudentDingMapping, Class } from '../entities';
|
||||
import { User, Student, StudentDingMapping, Class } from '../entities';
|
||||
import { DingTalkService } from './dingtalk.service';
|
||||
import { WeComService } from './wecom.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Department, User, Student, StudentDingMapping, Class])],
|
||||
imports: [TypeOrmModule.forFeature([User, Student, StudentDingMapping, Class])],
|
||||
providers: [DingTalkService, WeComService],
|
||||
exports: [DingTalkService, WeComService],
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Department } from '../entities/department.entity';
|
||||
import { User } from '../entities/user.entity';
|
||||
|
||||
interface WeComTokenResponse {
|
||||
@@ -35,8 +34,6 @@ export class WeComService {
|
||||
private tokenExpiresAt = 0;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Department)
|
||||
private readonly deptRepo: Repository<Department>,
|
||||
@InjectRepository(User)
|
||||
private readonly userRepo: Repository<User>,
|
||||
) {}
|
||||
@@ -97,45 +94,14 @@ export class WeComService {
|
||||
return body.userlist;
|
||||
}
|
||||
|
||||
async syncAll(): Promise<{ deptCount: number; userCount: number }> {
|
||||
async syncAll(): Promise<{ userCount: number }> {
|
||||
if (!this.configured) {
|
||||
this.logger.warn('WeCom not configured (WECOM_CORP_ID / WECOM_CORP_SECRET missing), skipping sync');
|
||||
return { deptCount: 0, userCount: 0 };
|
||||
return { userCount: 0 };
|
||||
}
|
||||
const token = await this.getAccessToken();
|
||||
const wxDepts = await this.fetchDepartments(token);
|
||||
|
||||
let deptCount = 0;
|
||||
for (const wd of wxDepts) {
|
||||
const sourceId = String(wd.id);
|
||||
let dept = await this.deptRepo.findOne({ where: { source: 'wecom', sourceId } });
|
||||
if (dept) {
|
||||
dept.name = wd.name;
|
||||
dept.parentSourceId = (wd.parentid ? String(wd.parentid) : undefined) as any;
|
||||
} else {
|
||||
dept = this.deptRepo.create({
|
||||
name: wd.name,
|
||||
source: 'wecom',
|
||||
sourceId,
|
||||
parentSourceId: (wd.parentid ? String(wd.parentid) : undefined),
|
||||
type: 'department',
|
||||
});
|
||||
deptCount++;
|
||||
}
|
||||
await this.deptRepo.save(dept);
|
||||
}
|
||||
|
||||
const syncedDepts = await this.deptRepo.find({ where: { source: 'wecom' } });
|
||||
const idMap = new Map(syncedDepts.map((d) => [d.sourceId, d.id]));
|
||||
for (const dept of syncedDepts) {
|
||||
if (dept.parentSourceId && idMap.has(dept.parentSourceId)) {
|
||||
dept.parentId = idMap.get(dept.parentSourceId)!;
|
||||
} else if (dept.parentSourceId === '0' || dept.parentSourceId === '1') {
|
||||
dept.parentId = undefined as any;
|
||||
}
|
||||
}
|
||||
await this.deptRepo.save(syncedDepts);
|
||||
|
||||
let userCount = 0;
|
||||
const seenUserIds = new Set<string>();
|
||||
for (const wd of wxDepts) {
|
||||
@@ -159,7 +125,7 @@ export class WeComService {
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`WeCom sync done: ${deptCount} new depts, ${userCount} new users`);
|
||||
return { deptCount, userCount };
|
||||
this.logger.log(`WeCom sync done: ${userCount} new users`);
|
||||
return { userCount };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { AppModule } from './app.module';
|
||||
import { seedDefaultCampus } from './departments/seed';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.setGlobalPrefix('api');
|
||||
app.enableCors();
|
||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
||||
const dataSource = app.get(DataSource);
|
||||
await seedDefaultCampus(dataSource);
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
console.log(`Server running on http://localhost:${process.env.PORT ?? 3000}`);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Locker } from '../entities/locker.entity';
|
||||
import { Deposit } from '../entities/deposit.entity';
|
||||
import { CheckInDto, CheckOutDto, TransferRoomDto } from './dto/occupancy.dto';
|
||||
import { RoomsService } from '../rooms/rooms.service';
|
||||
import { CampusScope } from '../common/campus-scope';
|
||||
|
||||
|
||||
@Injectable()
|
||||
export class OccupanciesService {
|
||||
@@ -29,7 +29,6 @@ export class OccupanciesService {
|
||||
@InjectRepository(Bed) private bedRepo: Repository<Bed>,
|
||||
@InjectRepository(Locker) private lockerRepo: Repository<Locker>,
|
||||
private dataSource: DataSource,
|
||||
private readonly scope: CampusScope,
|
||||
) {}
|
||||
|
||||
async findAll(query?: { roomId?: number; studentId?: number; active?: boolean }) {
|
||||
@@ -40,10 +39,6 @@ export class OccupanciesService {
|
||||
.leftJoinAndSelect('o.bed', 'bed')
|
||||
.leftJoinAndSelect('o.locker', 'locker')
|
||||
.orderBy('o.checkInDate', 'DESC');
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
if (scopeIds) {
|
||||
qb.andWhere('o.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
}
|
||||
if (query?.roomId) qb.andWhere('o.roomId = :roomId', { roomId: query.roomId });
|
||||
if (query?.studentId) qb.andWhere('o.studentId = :studentId', { studentId: query.studentId });
|
||||
if (query?.active) qb.andWhere('o.checkOutDate IS NULL');
|
||||
@@ -97,7 +92,6 @@ export class OccupanciesService {
|
||||
bedId: dto.bedId,
|
||||
lockerId: dto.lockerId,
|
||||
});
|
||||
occ.departmentId = room.departmentId;
|
||||
const saved = await this.repo.save(occ);
|
||||
|
||||
// 更新床位/柜子状态
|
||||
|
||||
@@ -28,9 +28,6 @@ export class CreateRoomDto {
|
||||
@IsNumber()
|
||||
monthlyRate?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
departmentId?: number;
|
||||
}
|
||||
|
||||
export class UpdateRoomDto {
|
||||
|
||||
@@ -360,8 +360,7 @@ export class RoomsController {
|
||||
monthlyRate,
|
||||
});
|
||||
});
|
||||
const departmentId = req.headers?.['x-campus-id'] ? parseInt(String(req.headers['x-campus-id']), 10) || undefined : undefined;
|
||||
const result = await this.service.batchImport(rows, departmentId);
|
||||
const result = await this.service.batchImport(rows);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Like, IsNull, Not, In, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
||||
import { CampusScope } from '../common/campus-scope';
|
||||
|
||||
import { Room } from '../entities/room.entity';
|
||||
import { Occupancy } from '../entities/occupancy.entity';
|
||||
import { RoomExpense } from '../entities/room-expense.entity';
|
||||
@@ -17,7 +17,6 @@ export class RoomsService {
|
||||
@InjectRepository(Room) private repo: Repository<Room>,
|
||||
@InjectRepository(Occupancy) private occRepo: Repository<Occupancy>,
|
||||
@InjectRepository(RoomExpense) private roomExpRepo: Repository<RoomExpense>,
|
||||
private readonly scope: CampusScope,
|
||||
@InjectRepository(Bed) private bedRepo: Repository<Bed>,
|
||||
@InjectRepository(Locker) private lockerRepo: Repository<Locker>,
|
||||
) {}
|
||||
@@ -69,8 +68,7 @@ export class RoomsService {
|
||||
const where: any = {};
|
||||
if (query?.building) where.building = query.building;
|
||||
if (!query?.includeArchived) where.status = Not('archived');
|
||||
const filteredWhere = await this.scope.filter(where);
|
||||
return this.repo.find({ where: filteredWhere, order: { roomNumber: 'ASC' } });
|
||||
return this.repo.find({ where, order: { roomNumber: 'ASC' } });
|
||||
}
|
||||
|
||||
async findOne(id: number) {
|
||||
@@ -92,8 +90,7 @@ export class RoomsService {
|
||||
async getRoomOverview(query?: { includeArchived?: boolean }) {
|
||||
const where: any = {};
|
||||
if (!query?.includeArchived) where.status = Not('archived');
|
||||
const filteredWhere = await this.scope.filter(where);
|
||||
const rooms = await this.repo.find({ where: filteredWhere, order: { building: 'ASC', roomNumber: 'ASC' } });
|
||||
const rooms = await this.repo.find({ where, order: { building: 'ASC', roomNumber: 'ASC' } });
|
||||
const result: any[] = [];
|
||||
for (const room of rooms) {
|
||||
const count = await this.occRepo.count({
|
||||
@@ -113,7 +110,6 @@ export class RoomsService {
|
||||
roomType: dto.roomType ?? parsed.roomType,
|
||||
capacity: dto.capacity ?? parsed.capacity,
|
||||
});
|
||||
if (dto.departmentId) entity.departmentId = dto.departmentId;
|
||||
return this.repo.save(entity);
|
||||
}
|
||||
|
||||
@@ -184,20 +180,17 @@ export class RoomsService {
|
||||
|
||||
// 实时视图排除已归档房间;历史视图不排除——当时有人住的房间即使现在已归档也应显示。
|
||||
const rooms = await this.repo.find({
|
||||
where: await this.scope.filter(isHistorical ? {} : { status: Not('archived') }),
|
||||
where: isHistorical ? {} : { status: Not('archived') },
|
||||
order: { building: 'ASC', roomNumber: 'ASC' },
|
||||
});
|
||||
|
||||
// scope.filter() produces identical scope conditions within the same request;
|
||||
// extract once and spread to avoid redundant calls.
|
||||
const scopeWhere = await this.scope.filter({});
|
||||
const occupancies = await this.occRepo.find({
|
||||
where: isHistorical
|
||||
? [
|
||||
{ ...scopeWhere, checkInDate: LessThanOrEqual(targetDate), checkOutDate: IsNull() },
|
||||
{ ...scopeWhere, checkInDate: LessThanOrEqual(targetDate), checkOutDate: MoreThanOrEqual(targetDate) },
|
||||
{ checkInDate: LessThanOrEqual(targetDate), checkOutDate: IsNull() },
|
||||
{ checkInDate: LessThanOrEqual(targetDate), checkOutDate: MoreThanOrEqual(targetDate) },
|
||||
]
|
||||
: { ...scopeWhere, checkOutDate: IsNull() },
|
||||
: { checkOutDate: IsNull() },
|
||||
relations: ['student', 'tenant'],
|
||||
order: { checkInDate: 'ASC' },
|
||||
});
|
||||
@@ -297,7 +290,6 @@ export class RoomsService {
|
||||
rentalCategory?: string;
|
||||
monthlyRate?: number;
|
||||
}[],
|
||||
departmentId?: number,
|
||||
) {
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
@@ -322,7 +314,6 @@ export class RoomsService {
|
||||
roomType: row.roomType || parsed.roomType || undefined,
|
||||
rentalCategory: row.rentalCategory || undefined,
|
||||
monthlyRate: row.monthlyRate ?? undefined,
|
||||
departmentId: departmentId ?? undefined,
|
||||
}),
|
||||
);
|
||||
imported++;
|
||||
|
||||
@@ -6,7 +6,6 @@ import { SchedulesService } from './schedules.service';
|
||||
import { ClassSchedule, ScheduleType } from '../entities/class-schedule.entity';
|
||||
import { ClassroomRental } from '../entities/classroom-rental.entity';
|
||||
import { Class } from '../entities/class.entity';
|
||||
import { CampusScope } from '../common/campus-scope';
|
||||
|
||||
/** Build a mock query-builder where each chain method returns `this`. */
|
||||
function mockQueryBuilder<T>(results: T[] = []) {
|
||||
@@ -36,7 +35,6 @@ describe('SchedulesService — checkConflict', () => {
|
||||
{ provide: getRepositoryToken(ClassSchedule), useValue: mockRepo },
|
||||
{ provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } },
|
||||
{ provide: getRepositoryToken(ClassroomRental), useValue: { createQueryBuilder: jest.fn() } },
|
||||
{ provide: CampusScope, useValue: { getScopeDepartmentIds: jest.fn().mockResolvedValue(null), filter: jest.fn((w: unknown) => w) } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -138,13 +136,6 @@ describe('SchedulesService — getClassroomOccupancy', () => {
|
||||
{ provide: getRepositoryToken(ClassSchedule), useValue: { createQueryBuilder: jest.fn() } },
|
||||
{ provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } },
|
||||
{ provide: getRepositoryToken(ClassroomRental), useValue: { createQueryBuilder: jest.fn() } },
|
||||
{
|
||||
provide: CampusScope,
|
||||
useValue: {
|
||||
getScopeDepartmentIds: jest.fn().mockResolvedValue(null),
|
||||
filter: jest.fn((w: unknown) => w),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable, NotFoundException, ConflictException } from '@nestjs/common
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ClassSchedule, Class, ClassroomRental } from '../entities';
|
||||
import { CampusScope } from '../common/campus-scope';
|
||||
|
||||
import {
|
||||
CreateScheduleDto,
|
||||
UpdateScheduleDto,
|
||||
@@ -15,7 +15,6 @@ export class SchedulesService {
|
||||
constructor(
|
||||
@InjectRepository(ClassSchedule)
|
||||
private readonly scheduleRepo: Repository<ClassSchedule>,
|
||||
private readonly scope: CampusScope,
|
||||
@InjectRepository(Class) private readonly classRepo: Repository<Class>,
|
||||
@InjectRepository(ClassroomRental)
|
||||
private readonly rentalRepo: Repository<ClassroomRental>,
|
||||
@@ -23,10 +22,6 @@ export class SchedulesService {
|
||||
|
||||
async findAll(query: QueryScheduleDto) {
|
||||
const qb = this.scheduleRepo.createQueryBuilder('cs');
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
if (scopeIds) {
|
||||
qb.andWhere('cs.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
}
|
||||
|
||||
if (query.classroomId) qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId });
|
||||
if (query.classId) qb.andWhere('cs.classId = :classId', { classId: query.classId });
|
||||
@@ -50,9 +45,6 @@ export class SchedulesService {
|
||||
await this.checkConflict(dto.classroomId, dto.weekDay, dto.startTime, dto.endTime, dto.startDate, dto.endDate);
|
||||
|
||||
const schedule = this.scheduleRepo.create(dto);
|
||||
if (dto.departmentId) {
|
||||
schedule.departmentId = dto.departmentId;
|
||||
}
|
||||
const saved = await this.scheduleRepo.save(schedule);
|
||||
return this.findOne(saved.id);
|
||||
}
|
||||
@@ -130,10 +122,6 @@ export class SchedulesService {
|
||||
|
||||
async getWeeklyView(query: WeeklyViewQueryDto) {
|
||||
const qb = this.scheduleRepo.createQueryBuilder('cs');
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
if (scopeIds) {
|
||||
qb.andWhere('cs.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
}
|
||||
if (query.classroomId) {
|
||||
qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId });
|
||||
}
|
||||
|
||||
@@ -3,14 +3,14 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import {
|
||||
Department, DepartmentType, User, Role, Permission,
|
||||
User, Role, Permission,
|
||||
Tenant, Student, Room, Classroom, Occupancy,
|
||||
RoomExpense, ExpenseType, Class,
|
||||
ClassStudent, ClassTeacher, ClassSchedule,
|
||||
Bill, BillItem, Deposit,
|
||||
AttendanceRecord, ClassroomRental,
|
||||
StudentProfile, StudentEnrollment, ExamScore,
|
||||
LearningRecord, UserDepartment, TeacherRoleType,
|
||||
LearningRecord, TeacherRoleType,
|
||||
ClassType, ClassStatus, ScheduleType,
|
||||
} from '../entities';
|
||||
|
||||
@@ -29,7 +29,7 @@ function pick<T>(arr: T[]): T {
|
||||
@Injectable()
|
||||
export class SeedDevService {
|
||||
private readonly logger = new Logger(SeedDevService.name);
|
||||
private deptId = 1;
|
||||
|
||||
private cachedStudents: Student[] = [];
|
||||
private cachedRooms: Room[] = [];
|
||||
private cachedClassrooms: Classroom[] = [];
|
||||
@@ -38,11 +38,9 @@ export class SeedDevService {
|
||||
private cachedTenants: Tenant[] = [];
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Department) private deptRepo: Repository<Department>,
|
||||
@InjectRepository(Permission) private permRepo: Repository<Permission>,
|
||||
@InjectRepository(Role) private roleRepo: Repository<Role>,
|
||||
@InjectRepository(User) private userRepo: Repository<User>,
|
||||
@InjectRepository(UserDepartment) private userDeptRepo: Repository<UserDepartment>,
|
||||
@InjectRepository(Tenant) private tenantRepo: Repository<Tenant>,
|
||||
@InjectRepository(Student) private studentRepo: Repository<Student>,
|
||||
@InjectRepository(Room) private roomRepo: Repository<Room>,
|
||||
@@ -77,11 +75,11 @@ export class SeedDevService {
|
||||
// ═══════════════════ Layer 0 ══════════════════════════
|
||||
await this.seedPermissions();
|
||||
await this.seedExpenseTypes();
|
||||
await this.seedDepartments();
|
||||
|
||||
|
||||
// ═══════════════════ Layer 1 ══════════════════════════
|
||||
await this.seedRoles();
|
||||
await this.seedUsersAndDepartments();
|
||||
await this.seedUsers();
|
||||
|
||||
// ═══════════════════ Layer 2 ══════════════════════════
|
||||
await this.seedTenants();
|
||||
@@ -173,33 +171,7 @@ export class SeedDevService {
|
||||
this.logger.log(` ✓ ${types.length} expense types`);
|
||||
}
|
||||
|
||||
// ── 0c: departments ──────────────────────────────────
|
||||
|
||||
private async seedDepartments(): Promise<void> {
|
||||
// Reuse existing campus if seedDefaultCampus() already created it in main.ts
|
||||
let campus = await this.deptRepo.findOne({ where: { type: DepartmentType.CAMPUS } });
|
||||
if (!campus) {
|
||||
campus = await this.deptRepo.save({
|
||||
name: '主校区',
|
||||
type: DepartmentType.CAMPUS,
|
||||
sortOrder: 0,
|
||||
});
|
||||
}
|
||||
const subCount = await this.deptRepo.count({ where: { parentId: campus.id } });
|
||||
if (subCount === 0) {
|
||||
await this.deptRepo.save([
|
||||
{ name: '教务部', type: DepartmentType.DEPARTMENT, parentId: campus.id, sortOrder: 1 },
|
||||
{ name: '宿管部', type: DepartmentType.DEPARTMENT, parentId: campus.id, sortOrder: 2 },
|
||||
{ name: '财务部', type: DepartmentType.DEPARTMENT, parentId: campus.id, sortOrder: 3 },
|
||||
{ name: '恭学专升本', type: DepartmentType.DEPARTMENT, parentId: campus.id, sortOrder: 4 },
|
||||
{ name: '26定向', type: DepartmentType.DEPARTMENT, parentId: campus.id, sortOrder: 5 },
|
||||
{ name: '续住', type: DepartmentType.DEPARTMENT, parentId: campus.id, sortOrder: 6 },
|
||||
]);
|
||||
}
|
||||
this.deptId = campus.id;
|
||||
const total = await this.deptRepo.count();
|
||||
this.logger.log(` ✓ ${total} departments`);
|
||||
}
|
||||
|
||||
// ── 1a: roles ────────────────────────────────────────
|
||||
|
||||
@@ -236,9 +208,9 @@ export class SeedDevService {
|
||||
this.logger.log(` ✓ ${roles.length} roles`);
|
||||
}
|
||||
|
||||
// ── 1b: users + user_departments ─────────────────────
|
||||
// ── 1b: users ───────────────────────────────────────
|
||||
|
||||
private async seedUsersAndDepartments(): Promise<void> {
|
||||
private async seedUsers(): Promise<void> {
|
||||
const hash = await bcrypt.hash('123456', 10);
|
||||
const roles = await this.roleRepo.find();
|
||||
const superAdminRole = roles.find((r) => r.name === '超管');
|
||||
@@ -263,7 +235,6 @@ export class SeedDevService {
|
||||
roles: u.roles,
|
||||
});
|
||||
saved.push(user);
|
||||
await this.userDeptRepo.save({ userId: user.id, departmentId: this.deptId, isDefault: true });
|
||||
}
|
||||
// Load all users for later seed steps to reference
|
||||
this.cachedUsers = await this.userRepo.find();
|
||||
@@ -285,36 +256,36 @@ export class SeedDevService {
|
||||
|
||||
private async seedRooms(): Promise<void> {
|
||||
// Real data pattern: 单人间 (2号楼5层) + 四人间 (3/4/5/6号楼1-2层)
|
||||
const rooms: Array<{ roomNumber: string; building: string; floor: number; capacity: number; status: string; roomType: string; gender: string; rentalCategory: string; monthlyRate: number; departmentId: number }> = [
|
||||
const rooms: Array<{ roomNumber: string; building: string; floor: number; capacity: number; status: string; roomType: string; gender: string; rentalCategory: string; monthlyRate: number }> = [
|
||||
// 2号楼5层 单人间 pattern (real data: 2-502 through 2-519)
|
||||
{ roomNumber: '2-502', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800, departmentId: this.deptId },
|
||||
{ roomNumber: '2-503', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800, departmentId: this.deptId },
|
||||
{ roomNumber: '2-504', building: '2号楼', floor: 5, capacity: 1, status: 'available', roomType: '单人间', gender: '男', rentalCategory: 'long', monthlyRate: 800, departmentId: this.deptId },
|
||||
{ roomNumber: '2-505', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '男', rentalCategory: 'long', monthlyRate: 800, departmentId: this.deptId },
|
||||
{ roomNumber: '2-506', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800, departmentId: this.deptId },
|
||||
{ roomNumber: '2-507', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800, departmentId: this.deptId },
|
||||
{ roomNumber: '2-508', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800, departmentId: this.deptId },
|
||||
{ roomNumber: '2-509', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800, departmentId: this.deptId },
|
||||
{ roomNumber: '2-510', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800, departmentId: this.deptId },
|
||||
{ roomNumber: '2-511', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '男', rentalCategory: 'long', monthlyRate: 800, departmentId: this.deptId },
|
||||
{ roomNumber: '2-512', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800, departmentId: this.deptId },
|
||||
{ roomNumber: '2-513', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800, departmentId: this.deptId },
|
||||
{ roomNumber: '2-515', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800, departmentId: this.deptId },
|
||||
{ roomNumber: '2-516', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800, departmentId: this.deptId },
|
||||
{ roomNumber: '2-517', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800, departmentId: this.deptId },
|
||||
{ roomNumber: '2-518', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800, departmentId: this.deptId },
|
||||
{ roomNumber: '2-519', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800, departmentId: this.deptId },
|
||||
{ roomNumber: '2-502', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800 },
|
||||
{ roomNumber: '2-503', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800 },
|
||||
{ roomNumber: '2-504', building: '2号楼', floor: 5, capacity: 1, status: 'available', roomType: '单人间', gender: '男', rentalCategory: 'long', monthlyRate: 800 },
|
||||
{ roomNumber: '2-505', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '男', rentalCategory: 'long', monthlyRate: 800 },
|
||||
{ roomNumber: '2-506', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800 },
|
||||
{ roomNumber: '2-507', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800 },
|
||||
{ roomNumber: '2-508', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800 },
|
||||
{ roomNumber: '2-509', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800 },
|
||||
{ roomNumber: '2-510', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800 },
|
||||
{ roomNumber: '2-511', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '男', rentalCategory: 'long', monthlyRate: 800 },
|
||||
{ roomNumber: '2-512', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800 },
|
||||
{ roomNumber: '2-513', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800 },
|
||||
{ roomNumber: '2-515', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800 },
|
||||
{ roomNumber: '2-516', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800 },
|
||||
{ roomNumber: '2-517', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800 },
|
||||
{ roomNumber: '2-518', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800 },
|
||||
{ roomNumber: '2-519', building: '2号楼', floor: 5, capacity: 1, status: 'full', roomType: '单人间', gender: '女', rentalCategory: 'long', monthlyRate: 800 },
|
||||
// 1号楼 家庭房
|
||||
{ roomNumber: '1-2-301', building: '1号楼', floor: 3, capacity: 2, status: 'full', roomType: '家庭房', gender: '男', rentalCategory: 'long', monthlyRate: 1200, departmentId: this.deptId },
|
||||
{ roomNumber: '1-2-301', building: '1号楼', floor: 3, capacity: 2, status: 'full', roomType: '家庭房', gender: '男', rentalCategory: 'long', monthlyRate: 1200 },
|
||||
// 四人间 pattern
|
||||
{ roomNumber: '3-106', building: '3号楼', floor: 1, capacity: 4, status: 'available', roomType: '四人间', gender: '女', rentalCategory: 'long', monthlyRate: 1200, departmentId: this.deptId },
|
||||
{ roomNumber: '3-107', building: '3号楼', floor: 1, capacity: 4, status: 'full', roomType: '四人间', gender: '女', rentalCategory: 'long', monthlyRate: 1200, departmentId: this.deptId },
|
||||
{ roomNumber: '4-107', building: '4号楼', floor: 1, capacity: 4, status: 'available', roomType: '四人间', gender: '男', rentalCategory: 'long', monthlyRate: 1200, departmentId: this.deptId },
|
||||
{ roomNumber: '4-111', building: '4号楼', floor: 1, capacity: 4, status: 'available', roomType: '四人间', gender: '男', rentalCategory: 'long', monthlyRate: 1200, departmentId: this.deptId },
|
||||
{ roomNumber: '4-201', building: '4号楼', floor: 2, capacity: 4, status: 'available', roomType: '四人间', gender: '男', rentalCategory: 'long', monthlyRate: 1200, departmentId: this.deptId },
|
||||
{ roomNumber: '4-204', building: '4号楼', floor: 2, capacity: 4, status: 'available', roomType: '四人间', gender: '男', rentalCategory: 'long', monthlyRate: 1200, departmentId: this.deptId },
|
||||
{ roomNumber: '5-109', building: '5号楼', floor: 1, capacity: 4, status: 'available', roomType: '四人间', gender: '女', rentalCategory: 'long', monthlyRate: 1200, departmentId: this.deptId },
|
||||
{ roomNumber: '6-116', building: '6号楼', floor: 1, capacity: 4, status: 'available', roomType: '四人间', gender: '男', rentalCategory: 'long', monthlyRate: 1200, departmentId: this.deptId },
|
||||
{ roomNumber: '3-106', building: '3号楼', floor: 1, capacity: 4, status: 'available', roomType: '四人间', gender: '女', rentalCategory: 'long', monthlyRate: 1200 },
|
||||
{ roomNumber: '3-107', building: '3号楼', floor: 1, capacity: 4, status: 'full', roomType: '四人间', gender: '女', rentalCategory: 'long', monthlyRate: 1200 },
|
||||
{ roomNumber: '4-107', building: '4号楼', floor: 1, capacity: 4, status: 'available', roomType: '四人间', gender: '男', rentalCategory: 'long', monthlyRate: 1200 },
|
||||
{ roomNumber: '4-111', building: '4号楼', floor: 1, capacity: 4, status: 'available', roomType: '四人间', gender: '男', rentalCategory: 'long', monthlyRate: 1200 },
|
||||
{ roomNumber: '4-201', building: '4号楼', floor: 2, capacity: 4, status: 'available', roomType: '四人间', gender: '男', rentalCategory: 'long', monthlyRate: 1200 },
|
||||
{ roomNumber: '4-204', building: '4号楼', floor: 2, capacity: 4, status: 'available', roomType: '四人间', gender: '男', rentalCategory: 'long', monthlyRate: 1200 },
|
||||
{ roomNumber: '5-109', building: '5号楼', floor: 1, capacity: 4, status: 'available', roomType: '四人间', gender: '女', rentalCategory: 'long', monthlyRate: 1200 },
|
||||
{ roomNumber: '6-116', building: '6号楼', floor: 1, capacity: 4, status: 'available', roomType: '四人间', gender: '男', rentalCategory: 'long', monthlyRate: 1200 },
|
||||
];
|
||||
|
||||
const saved = await this.roomRepo.save(rooms);
|
||||
@@ -326,10 +297,10 @@ export class SeedDevService {
|
||||
|
||||
private async seedClassrooms(): Promise<void> {
|
||||
const classrooms = [
|
||||
{ name: '102', building: 'a座', floor: 1, capacity: 30, roomType: '大', supervisor: '陈浩', departmentId: this.deptId },
|
||||
{ name: '201', building: 'a座', floor: 2, capacity: 30, roomType: '大', supervisor: '刘老师', departmentId: this.deptId },
|
||||
{ name: '202', building: 'a座', floor: 2, capacity: 25, roomType: '次大', supervisor: '刘老师', departmentId: this.deptId },
|
||||
{ name: '301', building: 'b座', floor: 3, capacity: 20, roomType: '小', supervisor: '黄老师', departmentId: this.deptId },
|
||||
{ name: '102', building: 'a座', floor: 1, capacity: 30, roomType: '大', supervisor: '陈浩' },
|
||||
{ name: '201', building: 'a座', floor: 2, capacity: 30, roomType: '大', supervisor: '刘老师' },
|
||||
{ name: '202', building: 'a座', floor: 2, capacity: 25, roomType: '次大', supervisor: '刘老师' },
|
||||
{ name: '301', building: 'b座', floor: 3, capacity: 20, roomType: '小', supervisor: '黄老师' },
|
||||
];
|
||||
const saved = await this.classroomRepo.save(classrooms);
|
||||
this.cachedClassrooms = saved;
|
||||
@@ -340,38 +311,38 @@ export class SeedDevService {
|
||||
|
||||
private async seedStudents(): Promise<void> {
|
||||
// Real data pattern: organization fields like 恭学专升本, 26定向, 续住, etc.
|
||||
const students: Array<{ name: string; phone: string; studentNo: string; gender: string; ethnicity: string; organization: string; supervisor: string; departmentId: number; status: string }> = [
|
||||
const students: Array<{ name: string; phone: string; studentNo: string; gender: string; ethnicity: string; organization: string; supervisor: string; status: string }> = [
|
||||
// 26定向 students
|
||||
{ name: '聂天羽', phone: '12345678912', studentNo: '123456', gender: '女', ethnicity: '汉', organization: '恭学专升本', supervisor: '', departmentId: this.deptId, status: 'active' },
|
||||
{ name: '艾柯丽努尔·艾买尔江', phone: '', studentNo: '', gender: '女', ethnicity: '汉', organization: '26定向', supervisor: '', departmentId: this.deptId, status: 'active' },
|
||||
{ name: '陈昊天', phone: '', studentNo: '', gender: '男', ethnicity: '汉', organization: '续住', supervisor: '糕糕', departmentId: this.deptId, status: 'active' },
|
||||
{ name: '赵璟涵', phone: '', studentNo: '', gender: '女', ethnicity: '汉', organization: '26定向', supervisor: '', departmentId: this.deptId, status: 'active' },
|
||||
{ name: '周子涵', phone: '', studentNo: '', gender: '女', ethnicity: '汉', organization: '26定向', supervisor: '', departmentId: this.deptId, status: 'active' },
|
||||
{ name: '秦婧怡', phone: '', studentNo: '', gender: '女', ethnicity: '汉', organization: '26尊享+暑文', supervisor: '', departmentId: this.deptId, status: 'active' },
|
||||
{ name: '石欣欣', phone: '', studentNo: '', gender: '女', ethnicity: '汉', organization: '26尊享', supervisor: '', departmentId: this.deptId, status: 'active' },
|
||||
{ name: '李光铄', phone: '', studentNo: '', gender: '男', ethnicity: '汉', organization: '26定向', supervisor: '', departmentId: this.deptId, status: 'active' },
|
||||
{ name: '田芸竹', phone: '12345678920', studentNo: '123464', gender: '女', ethnicity: '汉', organization: '恭学专升本', supervisor: '', departmentId: this.deptId, status: 'active' },
|
||||
{ name: '荚欣语', phone: '12345678921', studentNo: '123465', gender: '女', ethnicity: '汉', organization: '恭学专升本', supervisor: '', departmentId: this.deptId, status: 'active' },
|
||||
{ name: '刘倬宁', phone: '12345678922', studentNo: '123466', gender: '女', ethnicity: '汉', organization: '恭学专升本', supervisor: '', departmentId: this.deptId, status: 'active' },
|
||||
{ name: '柴高星', phone: '', studentNo: '', gender: '女', ethnicity: '汉', organization: '26尊享+暑专+专冲', supervisor: '', departmentId: this.deptId, status: 'active' },
|
||||
{ name: '焦怡菲', phone: '', studentNo: '', gender: '女', ethnicity: '汉', organization: '26尊享+暑期+专冲+年前文化', supervisor: '', departmentId: this.deptId, status: 'active' },
|
||||
{ name: '王姿璇', phone: '', studentNo: '', gender: '女', ethnicity: '汉', organization: '26定向', supervisor: '', departmentId: this.deptId, status: 'active' },
|
||||
{ name: '寇星彤', phone: '12345678926', studentNo: '123470', gender: '女', ethnicity: '汉', organization: '恭学专升本', supervisor: '', departmentId: this.deptId, status: 'active' },
|
||||
{ name: '聂天羽', phone: '12345678912', studentNo: '123456', gender: '女', ethnicity: '汉', organization: '恭学专升本', supervisor: '', status: 'active' },
|
||||
{ name: '艾柯丽努尔·艾买尔江', phone: '', studentNo: '', gender: '女', ethnicity: '汉', organization: '26定向', supervisor: '', status: 'active' },
|
||||
{ name: '陈昊天', phone: '', studentNo: '', gender: '男', ethnicity: '汉', organization: '续住', supervisor: '糕糕', status: 'active' },
|
||||
{ name: '赵璟涵', phone: '', studentNo: '', gender: '女', ethnicity: '汉', organization: '26定向', supervisor: '', status: 'active' },
|
||||
{ name: '周子涵', phone: '', studentNo: '', gender: '女', ethnicity: '汉', organization: '26定向', supervisor: '', status: 'active' },
|
||||
{ name: '秦婧怡', phone: '', studentNo: '', gender: '女', ethnicity: '汉', organization: '26尊享+暑文', supervisor: '', status: 'active' },
|
||||
{ name: '石欣欣', phone: '', studentNo: '', gender: '女', ethnicity: '汉', organization: '26尊享', supervisor: '', status: 'active' },
|
||||
{ name: '李光铄', phone: '', studentNo: '', gender: '男', ethnicity: '汉', organization: '26定向', supervisor: '', status: 'active' },
|
||||
{ name: '田芸竹', phone: '12345678920', studentNo: '123464', gender: '女', ethnicity: '汉', organization: '恭学专升本', supervisor: '', status: 'active' },
|
||||
{ name: '荚欣语', phone: '12345678921', studentNo: '123465', gender: '女', ethnicity: '汉', organization: '恭学专升本', supervisor: '', status: 'active' },
|
||||
{ name: '刘倬宁', phone: '12345678922', studentNo: '123466', gender: '女', ethnicity: '汉', organization: '恭学专升本', supervisor: '', status: 'active' },
|
||||
{ name: '柴高星', phone: '', studentNo: '', gender: '女', ethnicity: '汉', organization: '26尊享+暑专+专冲', supervisor: '', status: 'active' },
|
||||
{ name: '焦怡菲', phone: '', studentNo: '', gender: '女', ethnicity: '汉', organization: '26尊享+暑期+专冲+年前文化', supervisor: '', status: 'active' },
|
||||
{ name: '王姿璇', phone: '', studentNo: '', gender: '女', ethnicity: '汉', organization: '26定向', supervisor: '', status: 'active' },
|
||||
{ name: '寇星彤', phone: '12345678926', studentNo: '123470', gender: '女', ethnicity: '汉', organization: '恭学专升本', supervisor: '', status: 'active' },
|
||||
// 续住 students
|
||||
{ name: '於嘉丽', phone: '', studentNo: '', gender: '女', ethnicity: '汉族', organization: '续住', supervisor: '泡泡', departmentId: this.deptId, status: 'active' },
|
||||
{ name: '郑斌', phone: '', studentNo: '', gender: '男', ethnicity: '汉族', organization: '续住', supervisor: '糕糕', departmentId: this.deptId, status: 'active' },
|
||||
{ name: '仵梓钰', phone: '', studentNo: '', gender: '女', ethnicity: '汉族', organization: '续住', supervisor: '方方', departmentId: this.deptId, status: 'active' },
|
||||
{ name: '覃鼎浩', phone: '', studentNo: '', gender: '男', ethnicity: '汉族', organization: '续住', supervisor: '云熙', departmentId: this.deptId, status: 'active' },
|
||||
{ name: '常智禹', phone: '', studentNo: '', gender: '男', ethnicity: '汉族', organization: '续住', supervisor: '方方', departmentId: this.deptId, status: 'active' },
|
||||
{ name: '郭庆泉', phone: '', studentNo: '', gender: '男', ethnicity: '汉族', organization: '续住', supervisor: '糕糕', departmentId: this.deptId, status: 'active' },
|
||||
{ name: '孙立欣', phone: '', studentNo: '', gender: '男', ethnicity: '汉族', organization: '续住', supervisor: '糕糕', departmentId: this.deptId, status: 'active' },
|
||||
{ name: '韩尧祖', phone: '', studentNo: '', gender: '男', ethnicity: '汉族', organization: '', supervisor: '', departmentId: this.deptId, status: 'active' },
|
||||
{ name: '陈亚津', phone: '', studentNo: '', gender: '女', ethnicity: '汉族', organization: '', supervisor: '', departmentId: this.deptId, status: 'active' },
|
||||
{ name: '孟思妍', phone: '', studentNo: '', gender: '女', ethnicity: '汉族', organization: '26定向', supervisor: '', departmentId: this.deptId, status: 'active' },
|
||||
{ name: '武嘉怡', phone: '', studentNo: '', gender: '女', ethnicity: '汉族', organization: '26定向', supervisor: '', departmentId: this.deptId, status: 'active' },
|
||||
{ name: '刘禹含', phone: '', studentNo: '', gender: '女', ethnicity: '汉族', organization: '26定向', supervisor: '', departmentId: this.deptId, status: 'active' },
|
||||
{ name: '杜瑾慧', phone: '', studentNo: '', gender: '女', ethnicity: '汉族', organization: '26定向', supervisor: '', departmentId: this.deptId, status: 'active' },
|
||||
{ name: '孟凡志', phone: '', studentNo: '', gender: '男', ethnicity: '汉族', organization: '26定向', supervisor: '', departmentId: this.deptId, status: 'active' },
|
||||
{ name: '於嘉丽', phone: '', studentNo: '', gender: '女', ethnicity: '汉族', organization: '续住', supervisor: '泡泡', status: 'active' },
|
||||
{ name: '郑斌', phone: '', studentNo: '', gender: '男', ethnicity: '汉族', organization: '续住', supervisor: '糕糕', status: 'active' },
|
||||
{ name: '仵梓钰', phone: '', studentNo: '', gender: '女', ethnicity: '汉族', organization: '续住', supervisor: '方方', status: 'active' },
|
||||
{ name: '覃鼎浩', phone: '', studentNo: '', gender: '男', ethnicity: '汉族', organization: '续住', supervisor: '云熙', status: 'active' },
|
||||
{ name: '常智禹', phone: '', studentNo: '', gender: '男', ethnicity: '汉族', organization: '续住', supervisor: '方方', status: 'active' },
|
||||
{ name: '郭庆泉', phone: '', studentNo: '', gender: '男', ethnicity: '汉族', organization: '续住', supervisor: '糕糕', status: 'active' },
|
||||
{ name: '孙立欣', phone: '', studentNo: '', gender: '男', ethnicity: '汉族', organization: '续住', supervisor: '糕糕', status: 'active' },
|
||||
{ name: '韩尧祖', phone: '', studentNo: '', gender: '男', ethnicity: '汉族', organization: '', supervisor: '', status: 'active' },
|
||||
{ name: '陈亚津', phone: '', studentNo: '', gender: '女', ethnicity: '汉族', organization: '', supervisor: '', status: 'active' },
|
||||
{ name: '孟思妍', phone: '', studentNo: '', gender: '女', ethnicity: '汉族', organization: '26定向', supervisor: '', status: 'active' },
|
||||
{ name: '武嘉怡', phone: '', studentNo: '', gender: '女', ethnicity: '汉族', organization: '26定向', supervisor: '', status: 'active' },
|
||||
{ name: '刘禹含', phone: '', studentNo: '', gender: '女', ethnicity: '汉族', organization: '26定向', supervisor: '', status: 'active' },
|
||||
{ name: '杜瑾慧', phone: '', studentNo: '', gender: '女', ethnicity: '汉族', organization: '26定向', supervisor: '', status: 'active' },
|
||||
{ name: '孟凡志', phone: '', studentNo: '', gender: '男', ethnicity: '汉族', organization: '26定向', supervisor: '', status: 'active' },
|
||||
];
|
||||
|
||||
const saved = await this.studentRepo.save(students);
|
||||
@@ -394,7 +365,6 @@ export class SeedDevService {
|
||||
for (const c of clsData) {
|
||||
const saved = await this.classRepo.save({
|
||||
...c,
|
||||
departmentId: this.deptId,
|
||||
startDate: '2026-04-01',
|
||||
endDate: '2026-08-31',
|
||||
maxStudents: 30,
|
||||
@@ -481,7 +451,7 @@ export class SeedDevService {
|
||||
{ studentIdx: 28, roomIdx: 18, checkInDate: '2026-06-04', notes: '' }, // 孟凡志 -> 3-107
|
||||
];
|
||||
|
||||
const occupancies: Array<{ studentId: number; roomId: number; checkInDate: string; billingStartDate: string; rentalType: string; notes: string; departmentId: number; checkOutDate?: string; billingEndDate?: string }> = [];
|
||||
const occupancies: Array<{ studentId: number; roomId: number; checkInDate: string; billingStartDate: string; rentalType: string; notes: string; checkOutDate?: string; billingEndDate?: string }> = [];
|
||||
for (const m of mapping) {
|
||||
const student = this.cachedStudents[m.studentIdx];
|
||||
const room = this.cachedRooms[m.roomIdx];
|
||||
@@ -493,7 +463,6 @@ export class SeedDevService {
|
||||
billingStartDate: m.checkInDate,
|
||||
rentalType: room.rentalCategory,
|
||||
notes: m.notes,
|
||||
departmentId: this.deptId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -510,7 +479,7 @@ export class SeedDevService {
|
||||
|
||||
private async seedSchedules(): Promise<void> {
|
||||
const subjects = ['数学', '英语', '语文', '专业课', '政治', '历史'];
|
||||
const schedules: Array<{ classId: number; classroomId: number; weekDay: number; startTime: string; endTime: string; startDate: string; endDate: string; subject: string; teacherId: number; scheduleType: string; departmentId: number }> = [];
|
||||
const schedules: Array<{ classId: number; classroomId: number; weekDay: number; startTime: string; endTime: string; startDate: string; endDate: string; subject: string; teacherId: number; scheduleType: string }> = [];
|
||||
|
||||
for (const cls of this.cachedClasses.slice(0, 3)) {
|
||||
for (let day = 1; day <= 5; day++) {
|
||||
@@ -526,7 +495,6 @@ export class SeedDevService {
|
||||
subject: pick(subjects),
|
||||
teacherId: pick(this.cachedUsers).id,
|
||||
scheduleType: ScheduleType.INTERNAL,
|
||||
departmentId: this.deptId,
|
||||
},
|
||||
{
|
||||
classId: cls.id,
|
||||
@@ -539,7 +507,6 @@ export class SeedDevService {
|
||||
subject: pick(subjects),
|
||||
teacherId: pick(this.cachedUsers).id,
|
||||
scheduleType: ScheduleType.INTERNAL,
|
||||
departmentId: this.deptId,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -562,7 +529,7 @@ export class SeedDevService {
|
||||
{ roomIdx: 23, electric: { apr: 101.00, may: 98.18 }, water: { apr: 19.60, may: 19.60 } }, // 4-201
|
||||
];
|
||||
|
||||
const expenses: Array<{ roomId: number; expenseType: string; amount: number; periodStart: string; periodEnd: string; description: string; departmentId: number }> = [];
|
||||
const expenses: Array<{ roomId: number; expenseType: string; amount: number; periodStart: string; periodEnd: string; description: string }> = [];
|
||||
|
||||
for (const er of expenseRooms) {
|
||||
const room = this.cachedRooms[er.roomIdx];
|
||||
@@ -571,23 +538,23 @@ export class SeedDevService {
|
||||
expenses.push({
|
||||
roomId: room.id, expenseType: 'electricity', amount: er.electric.apr,
|
||||
periodStart: '2026-04-01', periodEnd: '2026-04-30',
|
||||
description: `电费 - ${room.roomNumber} 4月`, departmentId: this.deptId,
|
||||
description: `电费 - ${room.roomNumber} 4月`,
|
||||
});
|
||||
expenses.push({
|
||||
roomId: room.id, expenseType: 'water', amount: er.water.apr,
|
||||
periodStart: '2026-04-01', periodEnd: '2026-04-30',
|
||||
description: `水费 - ${room.roomNumber} 4月`, departmentId: this.deptId,
|
||||
description: `水费 - ${room.roomNumber} 4月`,
|
||||
});
|
||||
// May
|
||||
expenses.push({
|
||||
roomId: room.id, expenseType: 'electricity', amount: er.electric.may,
|
||||
periodStart: '2026-05-01', periodEnd: '2026-05-31',
|
||||
description: `电费 - ${room.roomNumber} 5月`, departmentId: this.deptId,
|
||||
description: `电费 - ${room.roomNumber} 5月`,
|
||||
});
|
||||
expenses.push({
|
||||
roomId: room.id, expenseType: 'water', amount: er.water.may,
|
||||
periodStart: '2026-05-01', periodEnd: '2026-05-31',
|
||||
description: `水费 - ${room.roomNumber} 5月`, departmentId: this.deptId,
|
||||
description: `水费 - ${room.roomNumber} 5月`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -600,7 +567,7 @@ export class SeedDevService {
|
||||
private async seedDeposits(): Promise<void> {
|
||||
// Real data: deposits tied to specific students
|
||||
const depositStudents = [24, 25, 26, 27, 28, 19, 2]; // indices into cachedStudents
|
||||
const deposits: Array<{ studentId: number; amount: number; status: string; paidDate: string; departmentId: number }> = [];
|
||||
const deposits: Array<{ studentId: number; amount: number; status: string; paidDate: string }> = [];
|
||||
|
||||
for (const idx of depositStudents) {
|
||||
const s = this.cachedStudents[idx];
|
||||
@@ -610,13 +577,12 @@ export class SeedDevService {
|
||||
amount: 500,
|
||||
status: 'paid',
|
||||
paidDate: s.id <= this.cachedStudents[25].id ? '2026-07-12' : '2026-06-04',
|
||||
departmentId: this.deptId,
|
||||
});
|
||||
}
|
||||
|
||||
// Manual deposits with custom amounts
|
||||
deposits.push({ studentId: this.cachedStudents[19].id, amount: 200, status: 'paid', paidDate: '2026-03-01', departmentId: this.deptId }); // 常智禹
|
||||
deposits.push({ studentId: this.cachedStudents[2].id, amount: 121.60, status: 'paid', paidDate: '2026-03-01', departmentId: this.deptId }); // 陈昊天
|
||||
deposits.push({ studentId: this.cachedStudents[19].id, amount: 200, status: 'paid', paidDate: '2026-03-01' }); // 常智禹
|
||||
deposits.push({ studentId: this.cachedStudents[2].id, amount: 121.60, status: 'paid', paidDate: '2026-03-01' }); // 陈昊天
|
||||
|
||||
await this.depositRepo.save(deposits);
|
||||
this.logger.log(` ✓ ${deposits.length} deposits`);
|
||||
@@ -649,7 +615,6 @@ export class SeedDevService {
|
||||
personalAmount: 0,
|
||||
totalAmount: bd.sharedApr,
|
||||
status: 'paid',
|
||||
departmentId: this.deptId,
|
||||
});
|
||||
await this.billItemRepo.save([
|
||||
{ billId: billApr.id, roomId: room.id, expenseType: 'electricity', description: '电费分摊', days: 30, studentAmount: Math.round(bd.sharedApr * 0.82 * 100) / 100 },
|
||||
@@ -665,7 +630,6 @@ export class SeedDevService {
|
||||
personalAmount: 0,
|
||||
totalAmount: bd.sharedMay,
|
||||
status: 'paid',
|
||||
departmentId: this.deptId,
|
||||
});
|
||||
await this.billItemRepo.save([
|
||||
{ billId: billMay.id, roomId: room.id, expenseType: 'electricity', description: '电费分摊', days: 31, studentAmount: Math.round(bd.sharedMay * 0.82 * 100) / 100 },
|
||||
@@ -680,7 +644,7 @@ export class SeedDevService {
|
||||
|
||||
private async seedAttendance(): Promise<void> {
|
||||
const statuses = ['present', 'absent', 'late', 'leave'];
|
||||
const records: Array<{ studentId: number; classId: number; attendanceDate: string; session: string; status: string; source: string; departmentId: number }> = [];
|
||||
const records: Array<{ studentId: number; classId: number; attendanceDate: string; session: string; status: string; source: string }> = [];
|
||||
|
||||
for (const cls of this.cachedClasses) {
|
||||
const classStudents = await this.classStudentRepo.find({ where: { classId: cls.id } });
|
||||
@@ -700,7 +664,6 @@ export class SeedDevService {
|
||||
session: 'am',
|
||||
status: pick(statuses),
|
||||
source: 'manual',
|
||||
departmentId: this.deptId,
|
||||
});
|
||||
count++;
|
||||
}
|
||||
@@ -724,7 +687,6 @@ export class SeedDevService {
|
||||
endDate: '2026-06-30',
|
||||
totalAmount: 30000,
|
||||
status: 'active',
|
||||
departmentId: this.deptId,
|
||||
});
|
||||
this.logger.log(' ✓ 1 classroom rental');
|
||||
}
|
||||
@@ -741,7 +703,6 @@ export class SeedDevService {
|
||||
subjectDirection: pick(['理科', '文科']),
|
||||
grade: '高三',
|
||||
campusLocation: '主校区',
|
||||
departmentId: this.deptId,
|
||||
}));
|
||||
await this.profileRepo.save(profiles);
|
||||
this.logger.log(` ✓ ${profiles.length} student profiles`);
|
||||
@@ -759,7 +720,6 @@ export class SeedDevService {
|
||||
startDate: '2026-04-01',
|
||||
endDate: '2026-08-31',
|
||||
status: 'active',
|
||||
departmentId: this.deptId,
|
||||
}));
|
||||
await this.enrollmentRepo.save(enrollments);
|
||||
this.logger.log(` ✓ ${enrollments.length} enrollments`);
|
||||
@@ -770,7 +730,7 @@ export class SeedDevService {
|
||||
private async seedExamScores(): Promise<void> {
|
||||
const subjects = ['数学', '英语', '语文', '专业课'];
|
||||
const exams = ['月考', '期中考试', '模拟考试'];
|
||||
const scores: Array<{ studentId: number; examType: string; examName: string; subject: string; score: number; examDate: string; departmentId: number }> = [];
|
||||
const scores: Array<{ studentId: number; examType: string; examName: string; subject: string; score: number; examDate: string }> = [];
|
||||
|
||||
for (const s of this.cachedStudents.slice(0, 10)) {
|
||||
for (const exam of exams) {
|
||||
@@ -782,7 +742,6 @@ export class SeedDevService {
|
||||
subject: subj,
|
||||
score: randInt(50, 100),
|
||||
examDate: `2026-0${randInt(4, 6)}-${String(randInt(1, 28)).padStart(2, '0')}`,
|
||||
departmentId: this.deptId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -802,7 +761,6 @@ export class SeedDevService {
|
||||
content: `学习状态:${pick(['良好', '一般', '需加强'])}`,
|
||||
followUpMethod: pick(['电话', '微信', '面谈']),
|
||||
nextStep: '继续跟进',
|
||||
departmentId: this.deptId,
|
||||
}));
|
||||
await this.learningRecordRepo.save(records);
|
||||
this.logger.log(` ✓ ${records.length} learning records`);
|
||||
|
||||
@@ -6,13 +6,13 @@ import {
|
||||
Bill, BillItem, User, Deposit,
|
||||
Classroom, Tenant, ClassroomRental, Permission, Role,
|
||||
Class, ClassStudent, ClassTeacher, ClassSchedule,
|
||||
AttendanceRecord, Department, UserDepartment,
|
||||
AttendanceRecord,
|
||||
StudentProfile, StudentEnrollment, ExamScore, LearningRecord,
|
||||
ExpenseType,
|
||||
} from '../entities';
|
||||
|
||||
const SEED_ENTITIES = [
|
||||
Department, User, Role, Permission, UserDepartment,
|
||||
User, Role, Permission,
|
||||
Tenant, Student, Room, Classroom, Occupancy,
|
||||
RoomExpense, ExpenseType, Class,
|
||||
ClassStudent, ClassTeacher, ClassSchedule, Bill, BillItem,
|
||||
|
||||
@@ -44,8 +44,6 @@ export class CreateStudentDto {
|
||||
@IsString()
|
||||
supervisor?: string;
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
departmentId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
|
||||
@@ -290,8 +290,7 @@ export class StudentsController {
|
||||
}
|
||||
}
|
||||
}
|
||||
const departmentId = req.headers?.['x-campus-id'] ? parseInt(String(req.headers['x-campus-id']), 10) || undefined : undefined;
|
||||
const result = await this.service.batchImport(rows, departmentId);
|
||||
const result = await this.service.batchImport(rows);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
|
||||
@@ -39,11 +39,7 @@ export class StudentsService {
|
||||
}
|
||||
|
||||
async create(dto: CreateStudentDto) {
|
||||
const entity = this.repo.create(dto);
|
||||
if (dto.departmentId) {
|
||||
entity.departmentId = dto.departmentId;
|
||||
}
|
||||
return this.repo.save(entity);
|
||||
return this.repo.save(this.repo.create(dto));
|
||||
}
|
||||
|
||||
async update(id: number, dto: UpdateStudentDto) {
|
||||
@@ -57,7 +53,6 @@ export class StudentsService {
|
||||
if (student.status === 'archived') {
|
||||
throw new BadRequestException('该学生已归档');
|
||||
}
|
||||
// 软删除:归档而非物理删除,保留历史数据
|
||||
await this.repo.update(id, { status: 'archived' });
|
||||
return { message: '已归档(数据已保留,可随时恢复)' };
|
||||
}
|
||||
@@ -110,7 +105,6 @@ export class StudentsService {
|
||||
supervisor?: string;
|
||||
tenantId?: number;
|
||||
}[],
|
||||
departmentId?: number,
|
||||
) {
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
@@ -136,7 +130,6 @@ export class StudentsService {
|
||||
organization: row.organization || undefined,
|
||||
supervisor: row.supervisor || undefined,
|
||||
tenantId: row.tenantId || undefined,
|
||||
departmentId: departmentId ?? undefined,
|
||||
}),
|
||||
);
|
||||
imported++;
|
||||
@@ -152,7 +145,6 @@ export class StudentsService {
|
||||
const student = await this.repo.findOne({ where: { id: studentId } });
|
||||
if (!student) throw new NotFoundException('学生不存在');
|
||||
|
||||
// Get all class enrollments for this student
|
||||
const enrollments = await this.classStudentRepo.find({
|
||||
where: { studentId },
|
||||
relations: ['class'],
|
||||
@@ -162,13 +154,11 @@ export class StudentsService {
|
||||
return { student, enrollments: [] };
|
||||
}
|
||||
|
||||
// For each enrollment, compute attendance stats
|
||||
const classIds = enrollments.map((e) => e.classId);
|
||||
const attendanceRecords = await this.attendanceRepo.find({
|
||||
where: { studentId, classId: In(classIds) },
|
||||
});
|
||||
|
||||
// Group attendance by class
|
||||
const attendanceByClass = new Map<number, AttendanceRecord[]>();
|
||||
for (const r of attendanceRecords) {
|
||||
const list = attendanceByClass.get(r.classId) || [];
|
||||
|
||||
@@ -3,10 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Not, IsNull } from 'typeorm';
|
||||
import {
|
||||
ClassSchedule,
|
||||
ClassStudent,
|
||||
ClassTeacher,
|
||||
Department,
|
||||
UserDepartment,
|
||||
} from '../entities';
|
||||
import { DingTalkService, DingTalkScheduleItem } from '../integration/dingtalk.service';
|
||||
|
||||
@@ -48,10 +45,6 @@ export class ScheduleSyncService {
|
||||
private readonly scheduleRepo: Repository<ClassSchedule>,
|
||||
@InjectRepository(ClassTeacher)
|
||||
private readonly classTeacherRepo: Repository<ClassTeacher>,
|
||||
@InjectRepository(Department)
|
||||
private readonly deptRepo: Repository<Department>,
|
||||
@InjectRepository(UserDepartment)
|
||||
private readonly userDeptRepo: Repository<UserDepartment>,
|
||||
private readonly dingTalkService: DingTalkService,
|
||||
) {}
|
||||
|
||||
@@ -75,7 +68,6 @@ export class ScheduleSyncService {
|
||||
status: 'active',
|
||||
teacherId: Not(IsNull()),
|
||||
},
|
||||
relations: ['department'],
|
||||
});
|
||||
|
||||
if (schedules.length === 0) {
|
||||
@@ -110,102 +102,84 @@ export class ScheduleSyncService {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 4: 按部门分组 ──
|
||||
const deptGroups = new Map<
|
||||
number,
|
||||
{ deptName: string; schedules: ClassSchedule[]; teacherIds: Set<number> }
|
||||
>();
|
||||
|
||||
// ── Step 4: Collect teacher IDs (no department entity) ──
|
||||
const teacherIds = new Set<number>();
|
||||
for (const s of schedules) {
|
||||
const deptId = s.departmentId || 0;
|
||||
if (!deptGroups.has(deptId)) {
|
||||
deptGroups.set(deptId, {
|
||||
deptName: (s.department as Department)?.name || `部门${deptId}`,
|
||||
schedules: [],
|
||||
teacherIds: new Set(),
|
||||
});
|
||||
}
|
||||
const group = deptGroups.get(deptId)!;
|
||||
group.schedules.push(s);
|
||||
if (s.teacherId) group.teacherIds.add(s.teacherId);
|
||||
if (s.teacherId) teacherIds.add(s.teacherId);
|
||||
}
|
||||
|
||||
// ── Step 5: 每个部门 → 考勤组 → 排班 ──
|
||||
// ── Step 5: Single group → attendance group → scheduling ──
|
||||
let syncedItems = 0;
|
||||
let skippedNoMapping = 0;
|
||||
let groupCount = 0;
|
||||
const groupDetails: ScheduleSyncResult['groups'] = [];
|
||||
|
||||
for (const [deptId, group] of deptGroups) {
|
||||
// 获取该部门教师的钉钉 userIds
|
||||
const dingUserIds: string[] = [];
|
||||
const teacherDingMap = new Map<number, string>(); // local teacherId → dingUserId
|
||||
for (const tid of group.teacherIds) {
|
||||
const dingId = userIdToDingId.get(tid);
|
||||
if (dingId) {
|
||||
dingUserIds.push(dingId);
|
||||
teacherDingMap.set(tid, dingId);
|
||||
}
|
||||
// Collect teacher→ding mapping
|
||||
const dingUserIds: string[] = [];
|
||||
const teacherDingMap = new Map<number, string>();
|
||||
for (const tid of teacherIds) {
|
||||
const dingId = userIdToDingId.get(tid);
|
||||
if (dingId) {
|
||||
dingUserIds.push(dingId);
|
||||
teacherDingMap.set(tid, dingId);
|
||||
}
|
||||
|
||||
if (dingUserIds.length === 0) {
|
||||
skippedNoMapping += group.schedules.length;
|
||||
this.logger.warn(`部门 ${group.deptName}: 无钉钉用户映射,跳过`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 该部门使用的班次 IDs
|
||||
const deptShiftIds = new Set<number>();
|
||||
for (const s of group.schedules) {
|
||||
const key = shiftKey(s.startTime, s.endTime);
|
||||
const sid = timeToShiftId.get(key);
|
||||
if (sid) deptShiftIds.add(sid);
|
||||
}
|
||||
|
||||
// 创建/匹配考勤组
|
||||
const groupName = `排课_${group.deptName}`;
|
||||
let attendanceGroupId: number;
|
||||
try {
|
||||
attendanceGroupId = await this.dingTalkService.findOrCreateAttendanceGroup(
|
||||
groupName,
|
||||
opUserId,
|
||||
dingUserIds,
|
||||
[...deptShiftIds],
|
||||
);
|
||||
groupCount++;
|
||||
} catch (e) {
|
||||
this.logger.error(`创建考勤组 ${groupName} 失败: ${(e as Error).message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 展开排课为每日排班
|
||||
const items = this.expandSchedules(
|
||||
group.schedules,
|
||||
teacherDingMap,
|
||||
timeToShiftId,
|
||||
startDate,
|
||||
endDate,
|
||||
);
|
||||
skippedNoMapping += group.schedules.length - new Set(items.map((i) => i.userid)).size;
|
||||
|
||||
// 分批写入(每次最多200条)
|
||||
for (let i = 0; i < items.length; i += 200) {
|
||||
const batch = items.slice(i, i + 200);
|
||||
try {
|
||||
await this.dingTalkService.scheduleUsers(attendanceGroupId, batch, opUserId);
|
||||
syncedItems += batch.length;
|
||||
} catch (e) {
|
||||
this.logger.error(`排班写入失败 (groupId=${attendanceGroupId}, offset=${i}): ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
groupDetails.push({
|
||||
deptName: group.deptName,
|
||||
groupId: attendanceGroupId,
|
||||
itemCount: items.length,
|
||||
});
|
||||
}
|
||||
|
||||
if (dingUserIds.length === 0) {
|
||||
this.logger.warn('无钉钉用户映射,跳过全部排班');
|
||||
return { scheduleCount: schedules.length, shiftCount, groupCount: 0, syncedItems: 0, skippedNoMapping: schedules.length, groups: [] };
|
||||
}
|
||||
|
||||
// All used shift IDs
|
||||
const allShiftIds = new Set<number>();
|
||||
for (const s of schedules) {
|
||||
const key = shiftKey(s.startTime, s.endTime);
|
||||
const sid = timeToShiftId.get(key);
|
||||
if (sid) allShiftIds.add(sid);
|
||||
}
|
||||
|
||||
// Create/find attendance group
|
||||
const groupName = '排课_全部';
|
||||
let attendanceGroupId: number;
|
||||
try {
|
||||
attendanceGroupId = await this.dingTalkService.findOrCreateAttendanceGroup(
|
||||
groupName,
|
||||
opUserId,
|
||||
dingUserIds,
|
||||
[...allShiftIds],
|
||||
);
|
||||
groupCount++;
|
||||
} catch (e) {
|
||||
this.logger.error(`创建考勤组 ${groupName} 失败: ${(e as Error).message}`);
|
||||
return { scheduleCount: schedules.length, shiftCount, groupCount: 0, syncedItems: 0, skippedNoMapping: schedules.length, groups: [] };
|
||||
}
|
||||
|
||||
// Expand schedules to daily items
|
||||
const items = this.expandSchedules(
|
||||
schedules,
|
||||
teacherDingMap,
|
||||
timeToShiftId,
|
||||
startDate,
|
||||
endDate,
|
||||
);
|
||||
skippedNoMapping = schedules.length - new Set(items.map((i) => i.userid)).size;
|
||||
|
||||
// Batch write (max 200 per batch)
|
||||
for (let i = 0; i < items.length; i += 200) {
|
||||
const batch = items.slice(i, i + 200);
|
||||
try {
|
||||
await this.dingTalkService.scheduleUsers(attendanceGroupId, batch, opUserId);
|
||||
syncedItems += batch.length;
|
||||
} catch (e) {
|
||||
this.logger.error(`排班写入失败 (groupId=${attendanceGroupId}, offset=${i}): ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
groupDetails.push({
|
||||
deptName: groupName,
|
||||
groupId: attendanceGroupId,
|
||||
itemCount: items.length,
|
||||
});
|
||||
this.logger.log(
|
||||
`排班同步完成: ${schedules.length} 条排课 → ${syncedItems} 条钉钉排班, ` +
|
||||
`${shiftCount} 班次, ${groupCount} 考勤组, 跳过 ${skippedNoMapping} 条无映射`,
|
||||
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
SyncState,
|
||||
StudentDingMapping,
|
||||
ClassSchedule,
|
||||
Department,
|
||||
UserDepartment,
|
||||
ClassTeacher,
|
||||
User,
|
||||
Student,
|
||||
@@ -26,8 +24,6 @@ import { ScheduleSyncService } from './schedule-sync.service';
|
||||
SyncState,
|
||||
StudentDingMapping,
|
||||
ClassSchedule,
|
||||
Department,
|
||||
UserDepartment,
|
||||
ClassTeacher,
|
||||
User,
|
||||
Student,
|
||||
|
||||
@@ -214,6 +214,6 @@ export class SyncService {
|
||||
|
||||
private async performWeComSync(_lastSyncAt: Date | null): Promise<number> {
|
||||
const result = await this.weComService.syncAll();
|
||||
return result.deptCount + result.userCount;
|
||||
return result.userCount;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user