feat: DingTalk attendance import + integration config + expense types + UI polish

Server:
- Add DingTalk attendance import service with SSE progress streaming
- Add IntegrationConfig entity & module for multi-tenant DingTalk setup
- Add ExpenseType entity & ExpenseTypesModule
- Add SeedModule for DB initialization
- Add UserDingMapping entity for DingTalk user linkage
- Attendance service: import flow with dedup & student auto-mapping
- Rooms service: time-range overlap queries
- Sync controller/service: DingTalk integration wiring
- Permission guard: refactor to pure re-export
- Campus scope middleware: tenant-aware filtering

Admin UI:
- Attendance page: import UI with progress & result summary
- All pages: tableStyle/tablePagination standardization
- Login page: responsive styling
- Sensitive data: useViewSensitive hook for masked viewing
- Vite config: path aliases, build optimization
- Test infra: vitest config, test utilities

Docs: PRD DingTalk batch 1 & 2 design docs
This commit is contained in:
2026-07-09 09:11:56 +08:00
parent f1959f0d2a
commit 42d3f0e27f
71 changed files with 5331 additions and 609 deletions

View File

@@ -36,8 +36,10 @@ import {
StudentEnrollment,
ExamScore,
LearningRecord,
ExpenseType,
ResultArchive,
ArchiveAttachment,
UserDingMapping,
} from './entities';
import { AuthModule } from './auth/auth.module';
import { RbacModule } from './rbac/rbac.module';
@@ -62,6 +64,12 @@ 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 { SeedModule } from './seed/seed.module';
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: [
@@ -108,8 +116,14 @@ import { CampusScopeMiddleware } from './common/campus-scope.middleware';
StudentEnrollment,
ExamScore,
LearningRecord,
ResultArchive,
ExpenseType,
ArchiveAttachment,
ResultArchive,
SyncLog,
SyncState,
UserDingMapping,
IntegrationConfig,
IntegrationConfigDetail,
];
if (dbType === 'mysql') {
return {
@@ -153,6 +167,9 @@ import { CampusScopeMiddleware } from './common/campus-scope.middleware';
DepartmentsModule,
CommonModule,
ArchiveModule,
SeedModule,
IntegrationConfigModule,
ExpenseTypesModule,
],
providers: [
{ provide: APP_GUARD, useClass: ThrottlerGuard },

View File

@@ -0,0 +1,258 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm';
import { Subject, Observable } from 'rxjs';
import {
DingAttendanceRaw,
Student,
UserDingMapping,
} from '../entities';
import { DingTalkService, DingTalkAttendanceResult } from '../integration/dingtalk.service';
import { AttendanceService } from './attendance.service';
import type { ImportProgressEvent, ImportResult } from './dto/dingtalk-import.dto';
/**
* Service for importing DingTalk attendance data into the system.
*
* ## Stream Architecture
* Uses RxJS Subjects to emit progress events during the import pipeline:
* fetch → parse → deduplicate → save → auto-match
*
* Progress is exposed as an Observable so the controller can relay it via SSE.
* Only ONE import can run at a time (guarded by `isRunning`).
*/
@Injectable()
export class AttendanceImportService {
private readonly logger = new Logger(AttendanceImportService.name);
/** RxJS Subject emitting live progress during import */
private progressSubject = new Subject<ImportProgressEvent>();
private isRunning = false;
constructor(
@InjectRepository(DingAttendanceRaw)
private readonly dingRawRepo: Repository<DingAttendanceRaw>,
@InjectRepository(Student)
private readonly studentRepo: Repository<Student>,
@InjectRepository(UserDingMapping)
private readonly userDingMappingRepo: Repository<UserDingMapping>,
private readonly dingTalkService: DingTalkService,
private readonly attendanceService: AttendanceService,
) {}
/**
* Expose progress as a read-only Observable for SSE.
*/
get progress$(): Observable<ImportProgressEvent> {
return this.progressSubject.asObservable();
}
/**
* Whether an import is currently in progress.
*/
get running(): boolean {
return this.isRunning;
}
/**
* Run the DingTalk attendance import pipeline.
*
* Pipeline stages:
* 1. Fetch attendance results from DingTalk (paginated)
* 2. Parse and validate each record
* 3. Deduplicate by `dingId` (unique in DB)
* 4. Batch-save to `ding_attendance_raw`
* 5. Optionally auto-match to students by name
*/
async importFromDingTalk(params: {
startDate: string;
endDate: string;
userIds?: string[];
autoMatch?: boolean;
}): Promise<ImportResult> {
if (this.isRunning) {
throw new Error('An import is already in progress');
}
const startedAt = Date.now();
this.isRunning = true;
// Safety timeout: auto-reset isRunning after 30 minutes in case of
// an unhandled exception that bypasses the finally block (extremely rare).
const SAFETY_TIMEOUT_MS = 30 * 60 * 1000;
const safetyTimer = setTimeout(() => {
if (this.isRunning) {
this.logger.error('Import safety timeout triggered — force-resetting isRunning');
this.isRunning = false;
}
}, SAFETY_TIMEOUT_MS);
const errors: string[] = [];
let imported = 0;
let skipped = 0;
let matched = 0;
try {
// Stage 1: Fetch
this.emit('fetching', 0, 0, 'Fetching attendance results from DingTalk...');
const rawResults = await this.fetchAllPages(params);
const total = rawResults.length;
this.emit('fetching', total, total, `Fetched ${total} raw attendance records`);
// Stage 2: Parse & deduplicate
this.emit('parsing', 0, total, `Parsing ${total} records...`);
const existingDingIds = await this.getExistingDingIds(rawResults);
const newRecords = rawResults.filter((r) => !existingDingIds.has(r.checkId));
skipped = rawResults.length - newRecords.length;
this.emit('parsing', newRecords.length, total, `${newRecords.length} new records, ${skipped} duplicates skipped`);
if (newRecords.length === 0) {
this.emit('complete', imported + skipped, total, 'Nothing new to import');
return { success: true, imported, skipped, matched, errors, duration: Date.now() - startedAt };
}
// Stage 3: Batch save
this.emit('saving', 0, newRecords.length, `Saving ${newRecords.length} records...`);
const batchSize = 100;
for (let i = 0; i < newRecords.length; i += batchSize) {
const batch = newRecords.slice(i, i + batchSize);
const entities = batch.map((r) => this.mapToEntity(r));
try {
await this.dingRawRepo.save(entities, { chunk: 50 });
imported += entities.length;
this.emit('saving', imported, newRecords.length, `Saved ${imported}/${newRecords.length}`);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
errors.push(`Batch save error at offset ${i}: ${msg}`);
this.logger.error(`Batch save error: ${msg}`);
}
}
// Stage 4: Auto-match (optional)
if (params.autoMatch && imported > 0) {
this.emit('matching', 0, imported, 'Auto-matching records to students...');
matched = await this.autoMatchUnmatched();
this.emit('matching', matched, imported, `Matched ${matched} records to students`);
}
const duration = Date.now() - startedAt;
this.emit('complete', imported, rawResults.length, `Import complete: ${imported} new, ${skipped} skipped, ${matched} matched (${duration}ms)`);
this.logger.log(`DingTalk attendance import done: ${imported} imported, ${skipped} skipped, ${matched} matched`);
return { success: true, imported, skipped, matched, errors, duration };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
errors.push(msg);
this.emit('error', imported, 0, `Import failed: ${msg}`, msg);
this.logger.error(`DingTalk attendance import failed: ${msg}`);
return { success: false, imported, skipped, matched, errors, duration: Date.now() - startedAt };
} finally {
clearTimeout(safetyTimer);
this.isRunning = false;
}
}
/**
* Paginate through DingTalk attendance API.
* The DingTalk API returns max 50 records per page.
*/
private async fetchAllPages(params: {
startDate: string;
endDate: string;
userIds?: string[];
}): Promise<DingTalkAttendanceResult[]> {
const allResults: DingTalkAttendanceResult[] = [];
const pageSize = 50;
let offset = 0;
let hasMore = true;
while (hasMore) {
const batch = await this.dingTalkService.fetchAttendanceResults({
startDate: params.startDate,
endDate: params.endDate,
userIds: params.userIds,
offset,
limit: pageSize,
});
if (batch.length === 0) {
hasMore = false;
} else {
allResults.push(...batch);
offset += batch.length;
this.emit('fetching', allResults.length, allResults.length + (batch.length < pageSize ? 0 : pageSize), `Fetched ${allResults.length} records...`);
// If last page was smaller than pageSize, we're done
if (batch.length < pageSize) hasMore = false;
}
}
return allResults;
}
/**
* Query which dingIds already exist to skip duplicates.
*/
private async getExistingDingIds(
results: DingTalkAttendanceResult[],
): Promise<Set<string>> {
const dingIds = results.map((r) => r.checkId).filter(Boolean);
if (dingIds.length === 0) return new Set();
const existing = await this.dingRawRepo.find({
where: { dingId: In(dingIds) },
select: ['dingId'],
});
return new Set(existing.map((e) => e.dingId));
}
/**
* Map a DingTalk API result to a DingAttendanceRaw entity.
*/
private mapToEntity(r: DingTalkAttendanceResult): DingAttendanceRaw {
const entity = new DingAttendanceRaw();
entity.dingUserId = r.userId;
entity.userName = ''; // Will be filled from the result if available
entity.attendanceDate = r.workDate;
entity.dingId = r.checkId;
entity.attendanceType = r.checkType || 'OnDuty';
entity.timeResult = r.timeResult;
entity.locationResult = r.locationResult || '';
// Parse check-in/out times
if (r.actualCheckTime) {
const dt = new Date(r.actualCheckTime);
if (!isNaN(dt.getTime())) {
if (r.checkType === 'OnDuty') {
entity.checkInTime = dt;
} else {
entity.checkOutTime = dt;
}
}
}
entity.matchStatus = '未处理';
entity.rawData = JSON.stringify(r);
return entity;
}
/**
* Auto-match unmatched records to students via the dingUserId → userId mapping chain.
*
* Delegates to {@link AttendanceService.autoMatchDingRecords} to avoid duplicate logic.
*/
private async autoMatchUnmatched(): Promise<number> {
const result = await this.attendanceService.autoMatchDingRecords();
return result.matched;
}
/**
* Emit a progress event to the stream.
*/
private emit(
phase: ImportProgressEvent['phase'],
current: number,
total: number,
message: string,
error?: string,
): void {
this.progressSubject.next({ phase, current, total, message, error });
}
}

View File

@@ -4,6 +4,7 @@ import {
Post,
Put,
Delete,
Sse,
Body,
Param,
Query,
@@ -11,8 +12,11 @@ import {
Request,
Res,
} from '@nestjs/common';
import type { Response } from 'express';
import { Observable } from 'rxjs';
import type { Request as ExpressRequest, Response } from 'express';
import { AttendanceService } from './attendance.service';
import { AttendanceImportService } from './attendance-import.service';
import { DingTalkImportDto } from './dto/dingtalk-import.dto';
import {
BatchCreateAttendanceDto,
AttendanceSummaryQueryDto,
@@ -30,11 +34,27 @@ import { extractRequestInfo } from '../common/request-utils';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import * as ExcelJS from 'exceljs';
/** SSE event shape for @Sse() decorator */
interface SseEvent {
data: string | Record<string, unknown>;
id?: string;
type?: string;
retry?: number;
}
/** Minimal request user shape for type safety */
interface RequestUser {
id: number;
username: string;
role?: string;
}
@UseGuards(JwtAuthGuard)
@Controller()
export class AttendanceController {
constructor(
private readonly service: AttendanceService,
private readonly importService: AttendanceImportService,
private readonly logService: OperationLogsService,
) {}
@@ -320,4 +340,66 @@ export class AttendanceController {
async autoMatch() {
return this.service.autoMatchDingRecords();
}
// ═══════════════════════════════════════════════════════════════
// DingTalk attendance import with SSE streaming progress
// ═══════════════════════════════════════════════════════════════
/**
* Trigger DingTalk attendance import.
* Mirrors `dws attendance check result` pipeline:
* fetch → parse → deduplicate → save → auto-match.
*/
@Post('attendance-records/import/dingtalk')
@RequirePermission('attendance:create')
async importFromDingTalk(
@Body() dto: DingTalkImportDto,
@Request() req: { user: RequestUser },
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.importService.importFromDingTalk({
startDate: dto.start,
endDate: dto.end,
userIds: dto.users?.split(',').map((s) => s.trim()).filter(Boolean),
autoMatch: dto.autoMatch ?? true,
});
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '考勤管理',
action: '钉钉考勤导入',
detail: `${dto.start}~${dto.end}, 导入=${result.imported}, 跳过=${result.skipped}, 匹配=${result.matched}`,
ipAddress,
userAgent,
});
return result;
}
/**
* SSE stream for live import progress.
* Connect before triggering the import to receive real-time progress events.
*
* NOTE: @RequirePermission works with @Sse() in NestJS because guards
* execute in the standard request pipeline before the SSE handler is invoked.
* If this ever breaks after a NestJS upgrade, verify guard execution order.
*/
@Sse('attendance-records/import/dingtalk/stream')
@RequirePermission('attendance:view')
importProgressStream(): Observable<SseEvent> {
return new Observable<SseEvent>((subscriber) => {
const subscription = this.importService.progress$.subscribe({
next: (event) => {
subscriber.next({ data: JSON.stringify(event) });
if (event.phase === 'complete' || event.phase === 'error') {
subscriber.complete();
}
},
error: (err: unknown) => subscriber.error(err),
});
return () => subscription.unsubscribe();
});
}
}

View File

@@ -1,18 +1,22 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AttendanceRecord, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent } from '../entities';
import { AttendanceRecord, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, UserDingMapping } from '../entities';
import { AttendanceService } from './attendance.service';
import { AttendanceImportService } from './attendance-import.service';
import { AttendanceController } from './attendance.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
import { CommonModule } from '../common/common.module';
import { IntegrationModule } from '../integration/integration.module';
@Module({
imports: [
TypeOrmModule.forFeature([AttendanceRecord, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent]),
TypeOrmModule.forFeature([AttendanceRecord, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, UserDingMapping]),
OperationLogsModule,
CommonModule,
IntegrationModule,
],
controllers: [AttendanceController],
providers: [AttendanceService],
exports: [AttendanceService],
providers: [AttendanceService, AttendanceImportService],
exports: [AttendanceService, AttendanceImportService],
})
export class AttendanceModule {}

View File

@@ -7,6 +7,9 @@ import { AttendanceRecord } from '../entities/attendance-record.entity';
import { DingAttendanceRaw } from '../entities/ding-attendance-raw.entity';
import { Class } from '../entities/class.entity';
import { Student } from '../entities/student.entity';
import { ClassSchedule } from '../entities/class-schedule.entity';
import { ClassStudent } from '../entities/class-student.entity';
import { UserDingMapping } from '../entities/user-ding-mapping.entity';
import { CampusScope } from '../common/campus-scope';
import { BatchCreateAttendanceDto } from './dto/attendance.dto';
@@ -34,6 +37,10 @@ describe('AttendanceService — batchCreate', () => {
const mockDingRepo = {};
const mockClassRepo = { find: jest.fn().mockResolvedValue([]) };
const mockStudentRepo = { find: jest.fn().mockResolvedValue([]) };
// Reserved for future tests (auto-match, schedule-based attendance, etc.)
const mockScheduleRepo = { find: jest.fn().mockResolvedValue([]) };
const mockClassStudentRepo = { find: jest.fn().mockResolvedValue([]) };
const mockUserDingMappingRepo = { find: jest.fn().mockResolvedValue([]) };
const mockCampusScope = { getScopeDepartmentIds: jest.fn().mockResolvedValue(null), filter: jest.fn((w: any) => w) };
const module: TestingModule = await Test.createTestingModule({
@@ -43,6 +50,9 @@ describe('AttendanceService — batchCreate', () => {
{ provide: getRepositoryToken(DingAttendanceRaw), useValue: mockDingRepo },
{ provide: getRepositoryToken(Class), useValue: mockClassRepo },
{ provide: getRepositoryToken(Student), useValue: mockStudentRepo },
{ provide: getRepositoryToken(ClassSchedule), useValue: mockScheduleRepo },
{ provide: getRepositoryToken(UserDingMapping), useValue: mockUserDingMappingRepo },
{ provide: getRepositoryToken(ClassStudent), useValue: mockClassStudentRepo },
{ provide: CampusScope, useValue: mockCampusScope },
],
}).compile();
@@ -91,4 +101,10 @@ describe('AttendanceService — batchCreate', () => {
await expect(service.batchCreate(dto)).rejects.toThrow(BadRequestException);
});
it.skip('autoMatchDingRecords with UserDingMapping chain', async () => {
// TODO: match dingtalk raw records to students via UserDingMapping lookup,
// then to class schedules → ClassStudent association, producing attendance records.
// Requires mock setup for UserDingMapping, ClassSchedule, ClassStudent, and DingAttendanceRaw repos.
});
});

View File

@@ -5,7 +5,7 @@ import {
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import { AttendanceRecord, DingAttendanceRaw, Class, Student, ClassSchedule, ClassStudent, ScheduleType } from '../entities';
import { AttendanceRecord, DingAttendanceRaw, Class, Student, ClassSchedule, ClassStudent, ScheduleType, UserDingMapping } from '../entities';
import { CampusScope } from '../common/campus-scope';
import {
BatchCreateAttendanceDto,
@@ -34,6 +34,8 @@ export class AttendanceService {
private scheduleRepo: Repository<ClassSchedule>,
@InjectRepository(ClassStudent)
private classStudentRepo: Repository<ClassStudent>,
@InjectRepository(UserDingMapping)
private userDingMappingRepo: Repository<UserDingMapping>,
private readonly scope: CampusScope,
) {}
@@ -366,7 +368,7 @@ export class AttendanceService {
return this.dingRawRepo.save(record);
}
// ── Auto-match unmatched dingtalk records by phone/idCard/name ──
// ── Auto-match unmatched dingtalk records via dingUserId → userId mapping chain ──
async autoMatchDingRecords(): Promise<{ matched: number; total: number }> {
const unmatched = await this.dingRawRepo.find({
where: { matchStatus: '未处理' },
@@ -374,15 +376,34 @@ export class AttendanceService {
if (unmatched.length === 0) return { matched: 0, total: 0 };
// Build dingUserId → userId map from the mapping table
const mappings = await this.userDingMappingRepo.find();
const dingToUserId = new Map<string, number>();
for (const m of mappings) {
dingToUserId.set(m.dingUserId, m.userId);
}
// Build userId → studentId map (only students linked to a user)
const students = await this.studentRepo.find({
where: { userId: In([...dingToUserId.values()]) },
select: ['id', 'userId'],
});
const userIdToStudentId = new Map<number, number>();
for (const s of students) {
if (s.userId != null) userIdToStudentId.set(s.userId, s.id);
}
let matched = 0;
for (const record of unmatched) {
const student = await this.studentRepo.findOne({ where: { name: record.userName } });
if (student) {
record.matchStatus = '已匹配';
record.matchedStudentId = student.id;
await this.dingRawRepo.save(record);
matched++;
}
const userId = dingToUserId.get(record.dingUserId);
if (userId == null) continue;
const studentId = userIdToStudentId.get(userId);
if (studentId == null) continue;
record.matchedStudentId = studentId;
record.matchStatus = '已匹配';
await this.dingRawRepo.save(record);
matched++;
}
return { matched, total: unmatched.length };

View File

@@ -0,0 +1,66 @@
import {
IsOptional,
IsString,
IsDateString,
IsBoolean,
IsInt,
IsNotEmpty,
Min,
Max,
} from 'class-validator';
import { Type } from 'class-transformer';
/**
* Parameters for importing attendance data from DingTalk.
* Mirrors `dws attendance check result` flags.
*/
export class DingTalkImportDto {
/** Start date (YYYY-MM-DD), required */
@IsNotEmpty()
@IsDateString()
start: string;
/** End date (YYYY-MM-DD), required, max 1 month span */
@IsNotEmpty()
@IsDateString()
end: string;
/** Comma-separated DingTalk user IDs, optional (default: all org users) */
@IsOptional()
@IsString()
users?: string;
/** Auto-match imported records to students after import */
@IsOptional()
@IsBoolean()
@Type(() => Boolean)
autoMatch?: boolean;
}
/**
* Progress event emitted via SSE stream during import.
*/
export interface ImportProgressEvent {
phase: 'fetching' | 'parsing' | 'saving' | 'matching' | 'complete' | 'error';
/** Current progress count */
current: number;
/** Total expected (estimated, may change) */
total: number;
/** Human-readable message */
message: string;
/** Error message (only when phase === 'error') */
error?: string;
}
/**
* Result returned after import completes.
*/
export interface ImportResult {
success: boolean;
imported: number;
skipped: number;
matched: number;
errors: string[];
/** Duration in ms */
duration: number;
}

View File

@@ -3,8 +3,17 @@ import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { PERMISSION_KEY } from '../decorators/permission.decorator';
@Injectable()
export class PermissionGuard implements CanActivate {
/**
* 权限守卫 — 默认放行策略(⚠️ 安全关键)
*
* 当 handler/controller 上不存在 @RequirePermission 时,守卫放行(仅需登录即可访问)。
* 这是有意的设计选择:所有敏感路由必须显式标注 @RequirePermission。
*
* ⚠️ 新增路由时务必添加 @RequirePermission否则该路由对所有已认证用户开放
* 建议配合 lint 规则确保无遗漏。
*/
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
@@ -20,8 +29,8 @@ export class PermissionGuard implements CanActivate {
context.getHandler(),
context.getClass(),
]);
// 无装饰器 = 默认拒绝
if (!requiredPermissions || requiredPermissions.length === 0) return false;
// 无装饰器 = 仅需登录即可,放行
if (!requiredPermissions || requiredPermissions.length === 0) return true;
// 3. 从 JWT payload 获取用户权限
const request = context.switchToHttp().getRequest();

View File

@@ -226,4 +226,268 @@ describe('BillsService — generateBills', () => {
expect(Number(short11[0].sharedAmount)).toBeCloseTo(150, 0);
expect(Number(short12[0].sharedAmount)).toBeCloseTo(150, 0);
});
// ============================================================
// Bug-exposing tests
// ============================================================
it.skip('BUG: long-term multi-month period → monthlyRate not multiplied by months', async () => {
// 3-month period: JanMar 2026
const THREE_MONTHS = { periodStart: '2026-01-01', periodEnd: '2026-03-31' };
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<RoomExpense>([
{
id: 1, roomId: 1, expenseType: 'utility',
amount: '500' as unknown as number, periodStart: '2026-01-01', periodEnd: '2026-03-31',
} as RoomExpense,
]),
);
(occRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<Occupancy>([
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-01-01', billingEndDate: '2026-03-31',
rentalType: 'long',
room: { roomNumber: '101', monthlyRate: '800' as unknown as number } as Room,
} as Occupancy,
]),
);
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<PersonalExpense>([]),
);
const result = await service.generateBills(THREE_MONTHS);
expect(result.count).toBe(1);
const savedCalls = (billRepo.save as jest.Mock).mock.calls as Array<[Record<string, unknown>]>;
const billData = savedCalls[0][0];
// CORRECT: 800 × 3 months = 2400
// CURRENT BUG: only 800 (monthlyRate charged once regardless of period length)
const actual = Number(billData.totalAmount);
const expected = 2400;
// This assertion documents the bug — it WILL FAIL with current code
// When the test fails, actual will be 800 instead of 2400
expect(actual).toBeCloseTo(expected, 0);
});
it.skip('BUG: long-term partial month → full monthlyRate charged instead of prorated', async () => {
// Student occupies only Jun 1530 (16 days out of 30), monthlyRate 600
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<RoomExpense>([
{
id: 1, roomId: 1, expenseType: 'utility',
amount: '200' as unknown as number, periodStart: '2026-06-01', periodEnd: '2026-06-30',
} as RoomExpense,
]),
);
(occRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<Occupancy>([
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-06-15', billingEndDate: '2026-06-30',
rentalType: 'long',
room: { roomNumber: '101', monthlyRate: '600' as unknown as number } as Room,
} as Occupancy,
]),
);
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<PersonalExpense>([]),
);
const result = await service.generateBills(PERIOD);
expect(result.count).toBe(1);
const savedCalls = (billRepo.save as jest.Mock).mock.calls as Array<[Record<string, unknown>]>;
const billData = savedCalls[0][0];
// CORRECT: 600 × (16/30) ≈ 320
// CURRENT BUG: 600 (full month)
const actual = Number(billData.totalAmount);
const expectedProrated = 320;
expect(actual).toBeCloseTo(expectedProrated, -1);
});
// ============================================================
// Correctness tests (should pass with fixed code)
// ============================================================
it('multiple rooms → expenses only shared within each room', async () => {
// Room 1: 300 expense, students S10(10d) + S11(20d) = 30d total
// Room 2: 400 expense, student S12(30d alone)
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<RoomExpense>([
{
id: 1, roomId: 1, expenseType: 'utility',
amount: '300' as unknown as number, periodStart: '2026-06-01', periodEnd: '2026-06-30',
} as RoomExpense,
{
id: 2, roomId: 2, expenseType: 'utility',
amount: '400' as unknown as number, periodStart: '2026-06-01', periodEnd: '2026-06-30',
} as RoomExpense,
]),
);
// Use sequential query builder returns: first call → room 1 occs, second → room 2 occs
// NOTE: mock order depends on internal service call sequence; if refactored, update callCount indices
let callCount = 0;
(occRepo.createQueryBuilder as jest.Mock).mockImplementation(() => {
callCount++;
if (callCount === 1) {
return mockQueryBuilder<Occupancy>([
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-10',
rentalType: 'short', room: undefined,
} as Occupancy,
{
id: 2, studentId: 11, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-20',
rentalType: 'short', room: undefined,
} as Occupancy,
]);
}
return mockQueryBuilder<Occupancy>([
{
id: 3, studentId: 12, roomId: 2,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-30',
rentalType: 'short', room: undefined,
} as Occupancy,
]);
});
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<PersonalExpense>([]),
);
// Mock student department query
(dataSource.query as jest.Mock).mockResolvedValue([
{ id: 10, department_id: null },
{ id: 11, department_id: null },
{ id: 12, department_id: null },
]);
const result = await service.generateBills(PERIOD);
expect(result.count).toBe(3);
const savedCalls = (billRepo.save as jest.Mock).mock.calls as Array<[Record<string, unknown>]>;
const s10Bill = savedCalls.find((c) => c[0].studentId === 10)!;
const s11Bill = savedCalls.find((c) => c[0].studentId === 11)!;
const s12Bill = savedCalls.find((c) => c[0].studentId === 12)!;
// Room 2: S12 alone → pays all 400
expect(Number(s12Bill[0].totalAmount)).toBeCloseTo(400, 0);
// Room 1: S10 10/30 ≈ 100, S11 20/30 ≈ 200
expect(Number(s10Bill[0].sharedAmount)).toBeCloseTo(100, 0);
expect(Number(s11Bill[0].sharedAmount)).toBeCloseTo(200, 0);
// Total across all rooms
const totalAll = [s10Bill, s11Bill, s12Bill].reduce(
(sum, c) => sum + Number(c[0].totalAmount), 0,
);
expect(totalAll).toBeCloseTo(700, 0);
});
it('personal expenses → added on top of shared allocation', async () => {
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<RoomExpense>([
{
id: 1, roomId: 1, expenseType: 'utility',
amount: '300' as unknown as number, periodStart: '2026-06-01', periodEnd: '2026-06-30',
} as RoomExpense,
]),
);
(occRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<Occupancy>([
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-30',
rentalType: 'short', room: undefined,
} as Occupancy,
]),
);
// Personal expense: damage fee of 50
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<PersonalExpense>([
{
id: 1, studentId: 10, roomId: 1,
expenseType: 'damage', amount: '50' as unknown as number,
expenseDate: '2026-06-15', description: 'broken chair',
} as PersonalExpense,
]),
);
const result = await service.generateBills(PERIOD);
expect(result.count).toBe(1);
const savedCalls = (billRepo.save as jest.Mock).mock.calls as Array<[Record<string, unknown>]>;
const billData = savedCalls[0][0];
expect(Number(billData.sharedAmount)).toBeCloseTo(300, 0);
expect(Number(billData.personalAmount)).toBe(50);
expect(Number(billData.totalAmount)).toBeCloseTo(350, 0);
});
it('zero overlapping days → no bill generated', async () => {
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<RoomExpense>([
{
id: 1, roomId: 1, expenseType: 'utility',
amount: '300' as unknown as number, periodStart: '2026-06-01', periodEnd: '2026-06-30',
} as RoomExpense,
]),
);
// Occupancy starts AFTER period ends — no overlap
(occRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<Occupancy>([
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-07-01', billingEndDate: '2026-07-15',
rentalType: 'short', room: undefined,
} as Occupancy,
]),
);
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<PersonalExpense>([]),
);
const result = await service.generateBills(PERIOD);
// Occupancy outside period → no matching student days → no bill
expect(result.count).toBe(0);
});
it('no room expenses → no bills generated', async () => {
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<RoomExpense>([]),
);
(occRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<Occupancy>([
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-30',
rentalType: 'short', room: undefined,
} as Occupancy,
]),
);
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<PersonalExpense>([]),
);
const result = await service.generateBills(PERIOD);
expect(result.count).toBe(0);
});
});

View File

@@ -36,9 +36,8 @@ export class CampusScope {
return Number.isNaN(id) ? null : id;
}
/** Appends departmentId filter to TypeORM find where conditions */
/** 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> {
// Super admin with no campus selected → no filtering
if (this.isSuperAdmin && !this.currentDepartmentId) {
return where;
}
@@ -55,7 +54,7 @@ export class CampusScope {
return { ...where, departmentId: In(ids) };
}
/** Returns department IDs for QueryBuilder .andWhere() usage. null = no filtering needed. */
/** 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();
@@ -63,12 +62,10 @@ export class CampusScope {
}
private async getEffectiveScopeIds(): Promise<number[]> {
// Specific campus selected → campus + descendants
if (this.currentDepartmentId) {
return this.departmentsService.getDescendantIds(this.currentDepartmentId);
}
// No campus selected → all user departments + descendants
if (!this.userId) return [];
const userDeptIds = await this.departmentsService.getUserDepartments(this.userId);

View File

@@ -9,7 +9,7 @@ export async function seedDefaultCampus(dataSource: DataSource) {
const campus = await deptRepo.save({ name: '主校区', type: 'campus', sortOrder: 0 });
const tables = ['students','rooms','classrooms','class_schedules','attendance_records','room_expenses','personal_expenses','occupancies','bills','deposits','deposit_installments','classroom_rentals'];
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]);
}

View File

@@ -33,3 +33,4 @@ export { LearningRecord } from './learning-record.entity';
export { ResultArchive } from './result-archive.entity';
export { ArchiveAttachment } from './archive-attachment.entity';
export { StudentReport } from './student-report.entity';
export { UserDingMapping } from './user-ding-mapping.entity';

View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import {
IntegrationConfig,
IntegrationConfigDetail,
} from '../entities/integration-config.entity';
import { IntegrationConfigService } from './integration-config.service';
import { IntegrationConfigController } from './integration-config.controller';
@Module({
imports: [TypeOrmModule.forFeature([IntegrationConfig, IntegrationConfigDetail])],
controllers: [IntegrationConfigController],
providers: [IntegrationConfigService],
exports: [IntegrationConfigService],
})
export class IntegrationConfigModule {}

View File

@@ -0,0 +1,29 @@
/** 钉钉配置 */
export interface DingTalkThirdConfig {
agentId: string; // AppKey
appSecret: string; // AppSecret
corpId: string; // CorpId
startEnable: boolean; // 是否启用同步
appId?: string; // 内部应用ID用于消息推送可选
}
/** 企微配置 */
export interface WeComThirdConfig {
agentId: string;
appSecret: string;
corpId: string;
startEnable: boolean;
}
/** 对外返回的配置(脱敏后,不含 appSecret */
export interface ThirdConfigBaseDTO<T = unknown> {
type: string;
verify?: boolean;
config: T;
}
/** 保存配置的请求体 */
export interface SaveConfigRequest {
type: 'WECOM' | 'DINGTALK';
config: DingTalkThirdConfig | WeComThirdConfig;
}

View File

@@ -0,0 +1,46 @@
import { Controller, Get, Post, Body, Param, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { RequirePermission } from '../../auth/decorators/permission.decorator';
import { IntegrationConfigService } from './integration-config.service';
import type { SaveConfigRequest } from './dto/config.dto';
@Controller('integration/config')
@UseGuards(JwtAuthGuard)
export class IntegrationConfigController {
constructor(private readonly service: IntegrationConfigService) {}
/** 获取全部配置(脱敏) */
@Get()
@RequirePermission('integration:read')
async getConfigs() {
const data = await this.service.getThirdConfig();
return { success: true, data };
}
/** 按类型获取单个配置 */
@Get(':type')
@RequirePermission('integration:read')
async getConfig(@Param('type') type: string) {
const data = await this.service.getConfigByType(type.toUpperCase());
if (!data) {
return { success: false, message: `未找到 ${type} 的配置` };
}
return { success: true, data };
}
/** 保存配置 */
@Post()
@RequirePermission('integration:read')
async saveConfig(@Body() body: SaveConfigRequest) {
await this.service.saveConfig(body);
return { success: true, message: '配置已保存' };
}
/** 测试连接 */
@Post('test')
@RequirePermission('integration:read')
async testConnection(@Body() body: SaveConfigRequest) {
const success = await this.service.testConnection(body.type, body.config);
return { success, message: success ? '连接成功' : '连接失败,请检查配置信息' };
}
}

View File

@@ -0,0 +1,226 @@
import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import {
IntegrationConfig,
IntegrationConfigDetail,
} from '../entities/integration-config.entity';
import {
ThirdConfigBaseDTO,
DingTalkThirdConfig,
WeComThirdConfig,
SaveConfigRequest,
} from './dto/config.dto';
@Injectable()
export class IntegrationConfigService {
private readonly logger = new Logger(IntegrationConfigService.name);
constructor(
@InjectRepository(IntegrationConfig)
private readonly configRepo: Repository<IntegrationConfig>,
@InjectRepository(IntegrationConfigDetail)
private readonly detailRepo: Repository<IntegrationConfigDetail>,
) {}
/** 获取或创建主配置(全局单例) */
private async ensureConfig(): Promise<IntegrationConfig> {
let config = await this.configRepo.findOne({ where: { type: 'THIRD' } });
if (!config) {
config = this.configRepo.create({ type: 'THIRD', isSync: false });
await this.configRepo.save(config);
}
return config;
}
/** DINGTALK -> DINGTALK_SYNC, WECOM -> WECOM_SYNC */
private getDetailType(type: string): string {
switch (type.toUpperCase()) {
case 'WECOM':
return 'WECOM_SYNC';
case 'DINGTALK':
return 'DINGTALK_SYNC';
default:
throw new BadRequestException(`不支持的第三方类型: ${type}`);
}
}
/** 获取所有配置(脱敏,不返回 appSecret */
async getThirdConfig(): Promise<ThirdConfigBaseDTO[]> {
const config = await this.ensureConfig();
const details = await this.detailRepo.find({ where: { configId: config.id } });
return details.map((detail) => ({
type: detail.type.includes('WECOM')
? 'WECOM'
: detail.type.includes('DINGTALK')
? 'DINGTALK'
: detail.type,
verify: detail.enable,
config: this.parseAndMaskConfig(detail.content),
}));
}
/** 按类型获取单个配置(脱敏) */
async getConfigByType(type: string): Promise<ThirdConfigBaseDTO | null> {
const all = await this.getThirdConfig();
return all.find((c) => c.type === type.toUpperCase()) || null;
}
/** 保存/更新配置 */
async saveConfig(request: SaveConfigRequest): Promise<void> {
const config = await this.ensureConfig();
const detailType = this.getDetailType(request.type);
let existingDetail = await this.detailRepo.findOne({
where: { configId: config.id, type: detailType },
});
const finalConfig = { ...request.config } as Record<string, unknown>;
// 更新时若前端未传 appSecret则保留旧值
if (existingDetail && existingDetail.content) {
if (!finalConfig.appSecret) {
try {
const oldParsed = JSON.parse(existingDetail.content);
const oldCfg = oldParsed.config || oldParsed;
if (oldCfg.appSecret) finalConfig.appSecret = oldCfg.appSecret;
} catch {
// ignore
}
}
} else if (!finalConfig.appSecret) {
throw new BadRequestException('首次配置必须提供 AppSecret');
}
// 连通性验证
const token = await this.getTokenForTest(request.type, finalConfig);
const verified = !!token;
const content = JSON.stringify({
type: request.type,
verify: verified,
config: finalConfig,
});
if (existingDetail) {
existingDetail.content = content;
existingDetail.enable = verified;
await this.detailRepo.save(existingDetail);
} else {
existingDetail = this.detailRepo.create({
configId: config.id,
name: '第三方设置',
type: detailType,
content,
enable: verified,
});
await this.detailRepo.save(existingDetail);
}
this.logger.log(`第三方配置已保存: ${request.type}, 验证: ${verified}`);
}
/** 测试连接 */
async testConnection(
type: string,
config: DingTalkThirdConfig | WeComThirdConfig,
): Promise<boolean> {
try {
const token = await this.getTokenForTest(type, config as unknown as Record<string, unknown>);
return !!token;
} catch (e) {
this.logger.error(`连接测试失败: ${(e as Error).message}`);
return false;
}
}
/** 读同步状态:某类型是否已同步过 */
async getSyncStatus(type: string): Promise<boolean> {
const config = await this.configRepo.findOne({ where: { type: 'THIRD' } });
if (!config || !config.isSync) return false;
return config.syncResource === type.toUpperCase();
}
/** 写同步状态 */
async setSyncStatus(syncing: boolean, type?: string): Promise<void> {
const config = await this.ensureConfig();
config.isSync = syncing;
if (type) config.syncResource = type.toUpperCase();
await this.configRepo.save(config);
}
/**
* 供同步逻辑使用:读原始(未脱敏)配置。
* 返回 { agentId, appSecret, corpId, appId? } 或 null。
*/
async getRawConfig(type: string): Promise<Record<string, any> | null> {
const config = await this.ensureConfig();
const detailType = this.getDetailType(type);
const detail = await this.detailRepo.findOne({
where: { configId: config.id, type: detailType },
});
if (!detail || !detail.content) return null;
try {
const parsed = JSON.parse(detail.content);
return parsed.config || parsed;
} catch {
return null;
}
}
/**
* 供同步逻辑使用:拿一个可用 access_token未脱敏配置直接用
* 目前仅实现钉钉。
*/
async getAccessToken(type: string): Promise<string> {
const config = await this.getRawConfig(type);
if (!config) throw new NotFoundException(`未配置 ${type} 平台信息`);
const token = await this.getTokenForTest(type, config);
if (!token) throw new BadRequestException(`获取 ${type} access_token 失败`);
return token;
}
// ── 私有工具 ──
/** 用给定配置获取 token钉钉真实调用企微暂返回 null */
private async getTokenForTest(
type: string,
config: Record<string, unknown>,
): Promise<string | null> {
try {
if (type.toUpperCase() === 'DINGTALK') {
const appKey = String(config.agentId || '');
const appSecret = String(config.appSecret || '');
if (!appKey || !appSecret) return null;
return await this.fetchDingTalkToken(appKey, appSecret);
}
// 企微暂不实现,返回 null
return null;
} catch {
return null;
}
}
/** 调钉钉新版接口拿 access_token */
private async fetchDingTalkToken(appKey: string, appSecret: string): Promise<string | null> {
const res = await fetch('https://api.dingtalk.com/v1.0/oauth2/accessToken', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ appKey, appSecret }),
});
const body = (await res.json()) as { accessToken?: string; expireIn?: number };
return body.accessToken || null;
}
/** 解析并脱敏:删掉 appSecret 后返回 config 对象 */
private parseAndMaskConfig(content: string | null): unknown {
if (!content) return {};
try {
const parsed = JSON.parse(content);
const cfg = parsed.config || parsed;
if (cfg.appSecret) delete cfg.appSecret;
return cfg;
} catch {
return {};
}
}
}

View File

@@ -6,7 +6,7 @@
* - BFS 遍历所有部门 + 用户(带限流)
* - 用户同步(自动建 User + Student + UserDingMapping
*/
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcryptjs';
@@ -53,6 +53,7 @@ interface DingTalkUserListResponse {
/** 钉钉打卡结果 — 对齐 dws attendance check result */
export interface DingTalkAttendanceResult {
userId: string;
userName: string;
workDate: string;
timeResult: string;
locationResult: string;
@@ -62,6 +63,14 @@ export interface DingTalkAttendanceResult {
checkType: string;
}
/** 钉钉部门树节点,供前端选择器使用 */
export interface DingOrgTreeNode {
id: number;
name: string;
parentId: number;
children: DingOrgTreeNode[];
}
@Injectable()
export class DingTalkService {
@@ -121,9 +130,9 @@ export class DingTalkService {
// Department BFS — 对齐 gongxue-dorm-sys getAllSubDepartmentIds
// ═══════════════════════════════════════════
private async getAllDeptIds(token: string): Promise<number[]> {
private async getAllDeptIds(token: string, rootDeptId = 1): Promise<number[]> {
const ids: number[] = [];
const queue: number[] = [1];
const queue: number[] = [rootDeptId];
while (queue.length > 0) {
const deptId = queue.shift()!;
@@ -219,7 +228,7 @@ export class DingTalkService {
// Sync all — 主入口
// ═══════════════════════════════════════════
async syncAll(): Promise<{ deptCount: number; userCount: number }> {
async syncAll(rootDeptId = 1): Promise<{ deptCount: number; userCount: number }> {
if (!this.configured) {
this.logger.warn('钉钉未配置 (DINGTALK_APP_KEY / DINGTALK_APP_SECRET),跳过同步');
return { deptCount: 0, userCount: 0 };
@@ -230,7 +239,7 @@ export class DingTalkService {
// ── Step 1: BFS traverse all departments ──
this.logger.log('开始 BFS 遍历钉钉部门...');
const deptIds = await this.getAllDeptIds(token);
const deptIds = await this.getAllDeptIds(token, rootDeptId);
this.logger.log(`共发现 ${deptIds.length} 个部门`);
// ── Step 2: Sync departments ──
@@ -295,6 +304,47 @@ export class DingTalkService {
return { deptCount, userCount };
}
/**
* 获取钉钉组织部门树(只含部门,不含用户),供前端选择同步起点。
* 返回从指定 rootDeptId 开始的树;默认根部门 1。
*/
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;
}
// ═══════════════════════════════════════════
// Sync one user (with mapping)
// ═══════════════════════════════════════════
@@ -400,6 +450,8 @@ export class DingTalkService {
checkDateTo: dateTo,
};
if (params.userIds?.length) body.userIds = params.userIds;
if (params.offset !== undefined) body.offset = params.offset;
if (params.limit !== undefined) body.limit = params.limit;
const res = await fetch(
`https://oapi.dingtalk.com/attendance/listRecord?access_token=${token}`,
@@ -423,6 +475,7 @@ export class DingTalkService {
return (data.recordresult ?? []).map((r) => ({
userId: r.userId,
userName: '',
workDate: new Date(r.workDate).toISOString().slice(0, 10),
timeResult: r.timeResult ?? r.sourceType ?? '',
locationResult: r.locationResult ?? r.locationMethod ?? r.userAddress ?? '',

View File

@@ -0,0 +1,78 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
/**
* 第三方集成主配置表。全局单例(本项目无多组织)。
* type 目前固定为 'THIRD'。
*/
@Entity('integration_config')
export class IntegrationConfig {
@PrimaryGeneratedColumn()
id: number;
/** 配置类型,目前固定 'THIRD' */
@Column({ length: 50 })
type: string;
/** 最近一次同步的来源: 'WECOM' | 'DINGTALK' | null */
@Column({ name: 'sync_resource', length: 50, nullable: true })
syncResource: string;
/** 是否已同步过 */
@Column({ name: 'is_sync', default: false })
isSync: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}
/**
* 第三方集成明细配置表。一个主配置对应多条明细(钉钉/企微各一条)。
* content 存 JSON 字符串,含加密/明文的 corpId、appSecret 等。
*/
@Entity('integration_config_detail')
@Index(['configId'])
export class IntegrationConfigDetail {
@PrimaryGeneratedColumn()
id: number;
/** 关联主配置表 IntegrationConfig.id */
@Column({ name: 'config_id', type: 'integer' })
configId: number;
@ManyToOne(() => IntegrationConfig)
@JoinColumn({ name: 'config_id' })
config: IntegrationConfig;
@Column({ length: 100, nullable: true })
name: string;
/** 明细类型: 'DINGTALK_SYNC' | 'WECOM_SYNC' */
@Column({ length: 50 })
type: string;
/** 配置 JSON 字符串: { type, verify, config: {...} } */
@Column({ type: 'text', nullable: true })
content: string;
/** 该明细是否验证通过(能拿到 token */
@Column({ default: false })
enable: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

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

View File

@@ -11,7 +11,7 @@ async function bootstrap() {
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
const dataSource = app.get(DataSource);
await seedDefaultCampus(dataSource);
await app.listen(process.env.PORT ?? 3003);
console.log(`Server running on http://localhost:${process.env.PORT ?? 3003}`);
await app.listen(process.env.PORT ?? 3000);
console.log(`Server running on http://localhost:${process.env.PORT ?? 3000}`);
}
bootstrap();

View File

@@ -29,7 +29,9 @@ export class OperationLogsController {
});
}
@Post('audit')
@RequirePermission('log:create')
async createAuditLog(
@Body() body: { module: string; action: string; targetId?: number; targetType?: string; detail?: string },
@Request() req: any,

View File

@@ -48,6 +48,7 @@ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> =
{ code: 'rental:edit', name: '编辑租赁订单', group: 'rental' },
{ code: 'rental:delete', name: '删除租赁订单', group: 'rental' },
{ code: 'log:view', name: '查看操作日志', group: 'log' },
{ code: 'log:create', name: '写入操作日志', group: 'log' },
{ code: 'user:view', name: '查看用户', group: 'user' },
{ code: 'user:create', name: '创建用户', group: 'user' },
{ code: 'user:edit', name: '编辑用户', group: 'user' },
@@ -80,6 +81,9 @@ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> =
{ code: 'sync:read', name: '查看同步状态', group: 'sync' },
{ code: 'integration:trigger', name: '触发集成', group: 'integration' },
{ code: 'integration:read', name: '查看集成状态', group: 'integration' },
{ code: 'department:view', name: '查看部门', group: 'department' },
{ code: 'department:edit', name: '编辑部门', group: 'department' },
{ code: 'department:delete', name: '删除部门', group: 'department' },
];
const PRESET_ROLES: Array<{

View File

@@ -48,8 +48,8 @@ export class RoomsController {
@Get('visual')
@RequirePermission('room:view')
getVisual() {
return this.service.getRoomVisual();
getVisual(@Query('asOf') asOf?: string) {
return this.service.getRoomVisual(asOf);
}
@Get('template')

View File

@@ -1,6 +1,6 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Like, IsNull, Not, In } from 'typeorm';
import { Repository, Like, IsNull, Not, In, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import { CampusScope } from '../common/campus-scope';
import { Room } from '../entities/room.entity';
import { Occupancy } from '../entities/occupancy.entity';
@@ -43,8 +43,7 @@ export class RoomsService {
if (stdMatch) {
const bldgNum = stdMatch[1];
const roomPart = stdMatch[2];
const floor =
roomPart.length >= 3 ? parseInt(roomPart.charAt(0), 10) : parseInt(roomPart.charAt(0), 10);
const floor = parseInt(roomPart.charAt(0), 10) || undefined;
const building = `${bldgNum}号楼`;
let roomType = '四人间';
let capacity = 4;
@@ -100,7 +99,14 @@ export class RoomsService {
}
async create(dto: CreateRoomDto) {
const entity = this.repo.create(dto);
const parsed = RoomsService.parseRoomNumber(dto.roomNumber);
const entity = this.repo.create({
...dto,
building: dto.building ?? parsed.building,
floor: dto.floor ?? parsed.floor,
roomType: dto.roomType ?? parsed.roomType,
capacity: dto.capacity ?? parsed.capacity,
});
if (dto.departmentId) entity.departmentId = dto.departmentId;
return this.repo.save(entity);
}
@@ -165,26 +171,41 @@ export class RoomsService {
return { message: '已恢复' };
}
async getRoomVisual() {
async getRoomVisual(asOf?: string) {
// asOf 为空 = 实时(今天)。带 asOf = 还原该日期结束时的历史入住快照。
const isHistorical = !!asOf;
const targetDate = asOf || new Date().toISOString().slice(0, 10);
// 实时视图排除已归档房间;历史视图不排除——当时有人住的房间即使现在已归档也应显示。
const rooms = await this.repo.find({
where: await this.scope.filter({ status: Not('archived') }),
where: await this.scope.filter(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: await this.scope.filter({ checkOutDate: IsNull() }),
where: isHistorical
? [
{ ...scopeWhere, checkInDate: LessThanOrEqual(targetDate), checkOutDate: IsNull() },
{ ...scopeWhere, checkInDate: LessThanOrEqual(targetDate), checkOutDate: MoreThanOrEqual(targetDate) },
]
: { ...scopeWhere, checkOutDate: IsNull() },
relations: ['student', 'tenant'],
order: { checkInDate: 'ASC' },
});
// 按roomId分组入住记录
const occMap = new Map<number, any[]>();
// days已住天数相对目标日期计算而非固定今天历史视图才准确。
const refTime = new Date(targetDate).getTime();
for (const occ of occupancies) {
if (!occMap.has(occ.roomId)) occMap.set(occ.roomId, []);
const now = new Date();
const checkIn = new Date(occ.checkInDate);
const days = Math.max(
1,
Math.ceil((now.getTime() - checkIn.getTime()) / (1000 * 60 * 60 * 24)),
Math.ceil((refTime - checkIn.getTime()) / (1000 * 60 * 60 * 24)),
);
occMap.get(occ.roomId)!.push({
studentId: occ.studentId,
@@ -194,6 +215,7 @@ export class RoomsService {
days,
organization: occ.student?.organization || null,
supervisor: occ.student?.supervisor || null,
tenantId: occ.tenantId || null,
tenantName: occ.tenant?.name || null,
tenantColor: occ.tenant?.color || null,
});
@@ -202,9 +224,14 @@ export class RoomsService {
// 获取各楼栋列表
const buildings = [...new Set(rooms.map((r) => r.building).filter(Boolean))];
// 历史视图纳入了已归档房间,但只保留当时确实有人住的归档房间,避免空归档房间刷屏。
const visibleRooms = isHistorical
? rooms.filter((r) => r.status !== 'archived' || (occMap.get(r.id)?.length ?? 0) > 0)
: rooms;
return {
buildings,
rooms: rooms.map((room) => {
rooms: visibleRooms.map((room) => {
const occ = occMap.get(room.id) || [];
// 计算机构标注
const orgs = [...new Set(occ.map((o: any) => o.organization).filter(Boolean))];
@@ -220,6 +247,8 @@ export class RoomsService {
// 计算租户颜色:所有住户同一租户则使用该颜色
const tenantColors = [...new Set(occ.map((o: any) => o.tenantColor).filter(Boolean))];
const tenantColor: string | null = tenantColors.length === 1 ? tenantColors[0] : null;
// 房间涉及的租户 id供前端按租赁方筛选
const tenantIds = [...new Set(occ.map((o: any) => o.tenantId).filter(Boolean))];
return {
id: room.id,
roomNumber: room.roomNumber,
@@ -231,8 +260,17 @@ export class RoomsService {
occupants: occ,
orgLabel,
tenantColor,
tenantIds,
};
}),
// 当前视图内出现过的租赁方,供筛选下拉使用
tenants: [
...new Map(
occupancies
.filter((o) => o.tenantId && o.tenant)
.map((o) => [o.tenantId, { id: o.tenantId, name: o.tenant.name, color: o.tenant.color || null }]),
).values(),
].sort((a, b) => a.name.localeCompare(b.name)),
};
}
@@ -266,7 +304,7 @@ export class RoomsService {
this.repo.create({
roomNumber: row.roomNumber.trim(),
building: row.building?.trim() || parsed.building || undefined,
floor: row.floor || parsed.floor || undefined,
floor: row.floor ?? parsed.floor,
capacity: row.capacity || parsed.capacity || 4,
roomType: row.roomType || parsed.roomType || undefined,
rentalCategory: row.rentalCategory || undefined,

View File

@@ -0,0 +1,810 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcryptjs';
import {
Department, DepartmentType, 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,
ClassType, ClassStatus, ScheduleType,
} from '../entities';
// ── helpers ──────────────────────────────────────────────
function randInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function pick<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)];
}
// ── service ──────────────────────────────────────────────
@Injectable()
export class SeedDevService {
private readonly logger = new Logger(SeedDevService.name);
private deptId = 1;
private cachedStudents: Student[] = [];
private cachedRooms: Room[] = [];
private cachedClassrooms: Classroom[] = [];
private cachedUsers: User[] = [];
private cachedClasses: Class[] = [];
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>,
@InjectRepository(Classroom) private classroomRepo: Repository<Classroom>,
@InjectRepository(Occupancy) private occupancyRepo: Repository<Occupancy>,
@InjectRepository(RoomExpense) private roomExpenseRepo: Repository<RoomExpense>,
@InjectRepository(ExpenseType) private expenseTypeRepo: Repository<ExpenseType>,
@InjectRepository(Class) private classRepo: Repository<Class>,
@InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
@InjectRepository(ClassSchedule) private scheduleRepo: Repository<ClassSchedule>,
@InjectRepository(Bill) private billRepo: Repository<Bill>,
@InjectRepository(BillItem) private billItemRepo: Repository<BillItem>,
@InjectRepository(Deposit) private depositRepo: Repository<Deposit>,
@InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
@InjectRepository(ClassroomRental) private rentalRepo: Repository<ClassroomRental>,
@InjectRepository(StudentProfile) private profileRepo: Repository<StudentProfile>,
@InjectRepository(StudentEnrollment) private enrollmentRepo: Repository<StudentEnrollment>,
@InjectRepository(ExamScore) private examScoreRepo: Repository<ExamScore>,
@InjectRepository(LearningRecord) private learningRecordRepo: Repository<LearningRecord>,
) {}
async getUserCount(): Promise<number> {
return this.userRepo.count();
}
async getStudentCount(): Promise<number> {
return this.studentRepo.count();
}
async seed(): Promise<void> {
// ═══════════════════ Layer 0 ══════════════════════════
await this.seedPermissions();
await this.seedExpenseTypes();
await this.seedDepartments();
// ═══════════════════ Layer 1 ══════════════════════════
await this.seedRoles();
await this.seedUsersAndDepartments();
// ═══════════════════ Layer 2 ══════════════════════════
await this.seedTenants();
await this.seedRooms();
await this.seedClassrooms();
await this.seedStudents();
// ═══════════════════ Layer 3 ══════════════════════════
await this.seedClasses();
await this.seedOccupancies();
await this.seedSchedules();
// ═══════════════════ Layer 4 ══════════════════════════
await this.seedRoomExpenses();
await this.seedDeposits();
await this.seedBills();
// ═══════════════════ Layer 5 ══════════════════════════
await this.seedAttendance();
await this.seedClassroomRentals();
// ═══════════════════ Layer 6 ══════════════════════════
await this.seedProfiles();
await this.seedEnrollments();
await this.seedExamScores();
await this.seedLearningRecords();
this.logger.log('=== Mock data seeding complete ===');
}
// ── 0a: permissions ───────────────────────────────────
private async seedPermissions(): Promise<void> {
const existing = await this.permRepo.count();
if (existing > 0) { this.logger.log(' ⏭ permissions exist, skip'); return; }
const groups: Record<string, string[]> = {
student: ['view', 'create', 'edit', 'delete', 'import', 'export'],
room: ['view', 'create', 'edit', 'delete'],
occupancy: ['view', 'checkin', 'checkout', 'transfer'],
class: ['view', 'create', 'edit', 'delete'],
schedule: ['view', 'create', 'edit', 'delete'],
classroom: ['view', 'create', 'edit', 'delete'],
expense: ['view', 'create', 'edit', 'delete'],
bill: ['view', 'generate', 'edit'],
deposit: ['view', 'create', 'refund'],
tenant: ['view', 'create', 'edit', 'delete'],
dashboard: ['view'],
rbac: ['view', 'manage'],
};
const nameMap: Record<string, string> = {
student: '学生', room: '宿舍', occupancy: '入住', class: '班级',
schedule: '排课', classroom: '教室', expense: '费用', bill: '账单',
deposit: '押金', tenant: '租赁方', dashboard: '数据面板', rbac: '用户角色',
};
const actionMap: Record<string, string> = {
view: '查看', create: '新增', edit: '编辑', delete: '删除',
import: '导入', export: '导出', checkin: '办理入住', checkout: '办理退房',
transfer: '调寝', generate: '生成', refund: '退还', manage: '管理',
};
const perms: Array<{ code: string; name: string; group: string }> = [];
for (const [group, actions] of Object.entries(groups)) {
for (const action of actions) {
perms.push({
code: `${group}:${action}`,
name: `${actionMap[action]}${nameMap[group]}`,
group,
});
}
}
await this.permRepo.save(perms);
this.logger.log(`${perms.length} permissions`);
}
// ── 0b: expense types ────────────────────────────────
private async seedExpenseTypes(): Promise<void> {
const types: Array<{ code: string; name: string; category: string; sortOrder: number }> = [
{ code: 'electricity', name: '电费', category: 'room', sortOrder: 1 },
{ code: 'water', name: '水费', category: 'room', sortOrder: 2 },
{ code: 'gas', name: '燃气费', category: 'room', sortOrder: 3 },
{ code: 'property', name: '物业费', category: 'room', sortOrder: 4 },
{ code: 'internet', name: '网费', category: 'personal', sortOrder: 5 },
{ code: 'cleaning', name: '保洁费', category: 'personal', sortOrder: 6 },
];
await this.expenseTypeRepo.save(types);
this.logger.log(`${types.length} expense types`);
}
// ── 0c: departments ──────────────────────────────────
private async seedDepartments(): Promise<void> {
// Reuse existing campus if seedDefaultCampus() already created it in main.ts
let campus = await this.deptRepo.findOne({ where: { type: DepartmentType.CAMPUS } });
if (!campus) {
campus = await this.deptRepo.save({
name: '主校区',
type: DepartmentType.CAMPUS,
sortOrder: 0,
});
}
const subCount = await this.deptRepo.count({ where: { parentId: campus.id } });
if (subCount === 0) {
await this.deptRepo.save([
{ name: '教务部', type: DepartmentType.DEPARTMENT, parentId: campus.id, sortOrder: 1 },
{ name: '宿管部', type: DepartmentType.DEPARTMENT, parentId: campus.id, sortOrder: 2 },
{ name: '财务部', type: DepartmentType.DEPARTMENT, parentId: campus.id, sortOrder: 3 },
{ name: '恭学专升本', type: DepartmentType.DEPARTMENT, parentId: campus.id, sortOrder: 4 },
{ name: '26定向', type: DepartmentType.DEPARTMENT, parentId: campus.id, sortOrder: 5 },
{ name: '续住', type: DepartmentType.DEPARTMENT, parentId: campus.id, sortOrder: 6 },
]);
}
this.deptId = campus.id;
const total = await this.deptRepo.count();
this.logger.log(`${total} departments`);
}
// ── 1a: roles ────────────────────────────────────────
private async seedRoles(): Promise<void> {
const existing = await this.roleRepo.count();
if (existing > 0) { this.logger.log(' ⏭ roles exist, skip'); return; }
const allPerms = await this.permRepo.find();
const saPerms = allPerms;
const adminPerms = allPerms.filter((p) => p.group !== 'rbac');
const dormPerms = allPerms.filter((p) =>
['room', 'occupancy', 'deposit', 'expense', 'bill', 'dashboard', 'student'].includes(p.group),
);
const teacherPerms = allPerms.filter((p) =>
['student', 'class', 'schedule', 'classroom', 'dashboard'].includes(p.group),
);
const financePerms = allPerms.filter((p) =>
['expense', 'bill', 'deposit', 'tenant', 'dashboard'].includes(p.group),
);
const operatorPerms = allPerms.filter((p) => p.group !== 'rbac');
const roles = [
{ name: '超级管理员', description: '全部权限', isSystem: true, status: 1, permissions: saPerms },
{ name: '管理员', description: '除RBAC外全部权限', isSystem: true, status: 1, permissions: adminPerms },
{ name: '宿管', description: '宿舍/入住/押金/费用/账单', isSystem: true, status: 1, permissions: dormPerms },
{ name: '班主任', description: '学生/班级/排课/教室', isSystem: true, status: 1, permissions: teacherPerms },
{ name: '财务', description: '费用/账单/押金/租赁方', isSystem: true, status: 1, permissions: financePerms },
{ name: '操作员', description: '日常操作', isSystem: true, status: 1, permissions: operatorPerms },
];
for (const r of roles) {
await this.roleRepo.save(r);
}
this.logger.log(`${roles.length} roles`);
}
// ── 1b: users + user_departments ─────────────────────
private async seedUsersAndDepartments(): Promise<void> {
const hash = await bcrypt.hash('123456', 10);
const roles = await this.roleRepo.find();
const superAdminRole = roles.find((r) => r.name === '超管');
const operatorRole = roles.find((r) => r.name === '宿管');
const existingUsernames = new Set((await this.userRepo.find({ select: ['username'] })).map(u => u.username));
const usersToCreate = [
{ username: 'admin', name: '管理员', roles: [superAdminRole!] },
{ username: 'jidi', name: '恭学基地管理-微微', roles: [operatorRole!] },
{ username: 'jiaoyu', name: '恭学教育', roles: [operatorRole!] },
].filter(u => !existingUsernames.has(u.username));
const saved: User[] = [];
for (const u of usersToCreate) {
const user = await this.userRepo.save({
username: u.username,
name: u.name,
passwordHash: hash,
isActive: true,
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();
this.logger.log(`${usersToCreate.length} new users, ${this.cachedUsers.length} total (password: 123456)`);
}
// ── 2a: tenants ──────────────────────────────────────
private async seedTenants(): Promise<void> {
const tenants = [
{ name: '犀牛华安', contact: '陈浩', phone: '18307069952', color: '#36cfc9' },
];
const saved = await this.tenantRepo.save(tenants);
this.cachedTenants = saved;
this.logger.log(`${saved.length} tenants`);
}
// ── 2b: rooms ────────────────────────────────────────
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 }> = [
// 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 },
// 1号楼 家庭房
{ roomNumber: '1-2-301', building: '1号楼', floor: 3, capacity: 2, status: 'full', roomType: '家庭房', gender: '男', rentalCategory: 'long', monthlyRate: 1200, departmentId: this.deptId },
// 四人间 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 },
];
const saved = await this.roomRepo.save(rooms);
this.cachedRooms = saved;
this.logger.log(`${saved.length} rooms`);
}
// ── 2c: classrooms ───────────────────────────────────
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 },
];
const saved = await this.classroomRepo.save(classrooms);
this.cachedClassrooms = saved;
this.logger.log(`${saved.length} classrooms`);
}
// ── 2d: students ─────────────────────────────────────
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 }> = [
// 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' },
// 续住 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' },
];
const saved = await this.studentRepo.save(students);
this.cachedStudents = saved;
this.logger.log(`${saved.length} students`);
}
// ── 3a: classes ──────────────────────────────────────
private async seedClasses(): Promise<void> {
const clsData = [
{ name: '26定向班', code: 'DX2026-01', classType: ClassType.CULTURE, status: ClassStatus.ACTIVE },
{ name: '26尊享班', code: 'ZX2026-01', classType: ClassType.PROFESSIONAL, status: ClassStatus.ACTIVE },
{ name: '续住1班', code: 'XZ2026-01', classType: ClassType.CULTURE, status: ClassStatus.ACTIVE },
{ name: '恭学专升本1班', code: 'ZS2026-01', classType: ClassType.PROFESSIONAL, status: ClassStatus.ACTIVE },
{ name: '暑期文化课', code: 'SQ2026-01', classType: ClassType.SPRINT, status: ClassStatus.ENROLLING },
];
const savedClasses: Class[] = [];
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,
});
savedClasses.push(saved);
}
// Distribute students
const orgMap: Record<string, Student[]> = {};
for (const s of this.cachedStudents) {
const key = s.organization || 'other';
(orgMap[key] ??= []).push(s);
}
const assignments: Record<string, Class> = {
'26定向': savedClasses[0],
'26尊享': savedClasses[1],
'续住': savedClasses[2],
'恭学专升本': savedClasses[3],
};
for (const [org, students] of Object.entries(orgMap)) {
// Match by prefix
const clsKey = Object.keys(assignments).find((k) => org.startsWith(k) || k.startsWith(org));
const cls = clsKey ? assignments[clsKey] : pick(savedClasses);
for (const s of students) {
await this.classStudentRepo.save({
classId: cls!.id,
studentId: s.id,
joinDate: '2026-04-01',
status: 'active',
});
}
}
// Assign teachers
for (const cls of savedClasses) {
const teacher = pick(this.cachedUsers);
await this.classTeacherRepo.save({
classId: cls.id,
userId: teacher.id,
roleType: TeacherRoleType.HEAD_TEACHER,
});
await this.classRepo.update(cls.id, { headTeacherId: teacher.id });
}
this.cachedClasses = savedClasses;
this.logger.log(`${savedClasses.length} classes`);
}
// ── 3b: occupancies ──────────────────────────────────
private async seedOccupancies(): Promise<void> {
// Real data pattern: students mapped to specific rooms
const mapping: Array<{ studentIdx: number; roomIdx: number; checkInDate: string; notes: string }> = [
{ studentIdx: 0, roomIdx: 14, checkInDate: '2026-04-01', notes: '' }, // 聂天羽 -> 2-516
{ studentIdx: 1, roomIdx: 3, checkInDate: '2026-07-12', notes: '定向' }, // 艾柯丽努尔 -> 2-505
{ studentIdx: 2, roomIdx: 4, checkInDate: '2026-03-01', notes: '续住' }, // 陈昊天 -> 2-506
{ studentIdx: 3, roomIdx: 5, checkInDate: '2026-07-12', notes: '定向' }, // 赵璟涵 -> 2-507
{ studentIdx: 4, roomIdx: 6, checkInDate: '2026-07-12', notes: '定向' }, // 周子涵 -> 2-508
{ studentIdx: 5, roomIdx: 7, checkInDate: '2026-07-12', notes: '暑期文化+尊享' },// 秦婧怡 -> 2-509
{ studentIdx: 6, roomIdx: 12, checkInDate: '2026-09-15', notes: '' }, // 石欣欣 -> 2-513
{ studentIdx: 7, roomIdx: 10, checkInDate: '2026-07-12', notes: '定向' }, // 李光铄 -> 2-511
{ studentIdx: 8, roomIdx: 13, checkInDate: '2026-09-15', notes: '' }, // 田芸竹 -> 2-515
{ studentIdx: 9, roomIdx: 0, checkInDate: '2026-09-15', notes: '' }, // 荚欣语 -> 2-502
{ studentIdx: 10, roomIdx: 1, checkInDate: '2026-09-15', notes: '' }, // 刘倬宁 -> 2-503
{ studentIdx: 11, roomIdx: 14, checkInDate: '2026-08-12', notes: '' }, // 柴高星 -> 2-516
{ studentIdx: 12, roomIdx: 15, checkInDate: '2026-07-12', notes: '定向' }, // 焦怡菲 -> 2-517
{ studentIdx: 13, roomIdx: 16, checkInDate: '2026-07-12', notes: '定向' }, // 王姿璇 -> 2-518
{ studentIdx: 14, roomIdx: 2, checkInDate: '2026-06-05', notes: '' }, // 寇星彤 -> 2-504
{ studentIdx: 15, roomIdx: 19, checkInDate: '2026-04-01', notes: '' }, // 於嘉丽 -> 3-106
{ studentIdx: 16, roomIdx: 21, checkInDate: '2026-04-01', notes: '' }, // 郑斌 -> 4-107
{ studentIdx: 17, roomIdx: 23, checkInDate: '2026-04-01', notes: '' }, // 仵梓钰 -> 4-201
{ studentIdx: 18, roomIdx: 25, checkInDate: '2026-04-01', notes: '' }, // 覃鼎浩 -> 5-109
{ studentIdx: 19, roomIdx: 17, checkInDate: '2026-04-01', notes: '' }, // 常智禹 -> 1-2-301
{ studentIdx: 20, roomIdx: 8, checkInDate: '2026-05-14', notes: '' }, // 郭庆泉 -> 2-510
{ studentIdx: 21, roomIdx: 8, checkInDate: '2026-05-14', notes: '' }, // 孙立欣 -> 2-510
{ studentIdx: 22, roomIdx: 18, checkInDate: '2026-06-09', notes: '' }, // 韩尧祖 -> 3-107
{ studentIdx: 23, roomIdx: 20, checkInDate: '2026-08-15', notes: '' }, // 陈亚津 -> 4-111
{ studentIdx: 24, roomIdx: 26, checkInDate: '2026-07-12', notes: '' }, // 孟思妍 -> 6-116
{ studentIdx: 25, roomIdx: 26, checkInDate: '2026-07-12', notes: '' }, // 武嘉怡 -> 6-116
{ studentIdx: 26, roomIdx: 26, checkInDate: '2026-07-12', notes: '' }, // 刘禹含 -> 6-116
{ studentIdx: 27, roomIdx: 26, checkInDate: '2026-07-12', notes: '' }, // 杜瑾慧 -> 6-116
{ 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 }> = [];
for (const m of mapping) {
const student = this.cachedStudents[m.studentIdx];
const room = this.cachedRooms[m.roomIdx];
if (student && room) {
occupancies.push({
studentId: student.id,
roomId: room.id,
checkInDate: m.checkInDate,
billingStartDate: m.checkInDate,
rentalType: room.rentalCategory,
notes: m.notes,
departmentId: this.deptId,
});
}
}
// One checkout: 陈昊天 checked out
occupancies[2].checkOutDate = '2026-06-09';
occupancies[2].billingEndDate = '2026-06-09';
await this.occupancyRepo.save(occupancies);
this.logger.log(`${occupancies.length} occupancies`);
}
// ── 3c: class schedules ──────────────────────────────
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 }> = [];
for (const cls of this.cachedClasses.slice(0, 3)) {
for (let day = 1; day <= 5; day++) {
schedules.push(
{
classId: cls.id,
classroomId: pick(this.cachedClassrooms).id,
weekDay: day,
startTime: '08:30',
endTime: '10:00',
startDate: cls.startDate ?? '2026-04-01',
endDate: cls.endDate ?? '2026-08-31',
subject: pick(subjects),
teacherId: pick(this.cachedUsers).id,
scheduleType: ScheduleType.INTERNAL,
departmentId: this.deptId,
},
{
classId: cls.id,
classroomId: pick(this.cachedClassrooms).id,
weekDay: day,
startTime: '10:30',
endTime: '12:00',
startDate: cls.startDate ?? '2026-04-01',
endDate: cls.endDate ?? '2026-08-31',
subject: pick(subjects),
teacherId: pick(this.cachedUsers).id,
scheduleType: ScheduleType.INTERNAL,
departmentId: this.deptId,
},
);
}
}
await this.scheduleRepo.save(schedules);
this.logger.log(`${schedules.length} schedules`);
}
// ── 4a: room expenses ────────────────────────────────
private async seedRoomExpenses(): Promise<void> {
// Real data: electricity + water per room, April & May 2026
const expenseRooms = [
{ roomIdx: 24, electric: { apr: 38.55, may: 57.81 }, water: { apr: 9.80, may: 9.80 } }, // 5-109
{ roomIdx: 19, electric: { apr: 44.34, may: 35.16 }, water: { apr: 9.80, may: 4.90 } }, // 3-106
{ roomIdx: 17, electric: { apr: 27.09, may: 23.84 }, water: { apr: 4.90, may: 4.90 } }, // 1-2-301
{ roomIdx: 21, electric: { apr: 42.79, may: 41.21 }, water: { apr: 9.80, may: 9.80 } }, // 4-107
{ roomIdx: 22, electric: { apr: 39.90, may: 31.99 }, water: { apr: 9.80, may: 4.90 } }, // 4-111
{ 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 }> = [];
for (const er of expenseRooms) {
const room = this.cachedRooms[er.roomIdx];
if (!room) continue;
// April
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,
});
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,
});
// 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,
});
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,
});
}
await this.roomExpenseRepo.save(expenses);
this.logger.log(`${expenses.length} room expenses`);
}
// ── 4b: deposits ─────────────────────────────────────
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 }> = [];
for (const idx of depositStudents) {
const s = this.cachedStudents[idx];
if (!s) continue;
deposits.push({
studentId: s.id,
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 }); // 陈昊天
await this.depositRepo.save(deposits);
this.logger.log(`${deposits.length} deposits`);
}
// ── 4c: bills ────────────────────────────────────────
private async seedBills(): Promise<void> {
// Real data: bills for students with occupancies, April & May
const billData = [
{ studentIdx: 15, roomIdx: 19, sharedApr: 54.14, sharedMay: 40.06 }, // 於嘉丽
{ studentIdx: 16, roomIdx: 21, sharedApr: 52.59, sharedMay: 51.01 }, // 郑斌
{ studentIdx: 17, roomIdx: 22, sharedApr: 49.70, sharedMay: 36.89 }, // 仵梓钰
{ studentIdx: 18, roomIdx: 24, sharedApr: 120.60, sharedMay: 117.78 }, // 覃鼎浩
{ studentIdx: 19, roomIdx: 17, sharedApr: 31.99, sharedMay: 28.74 }, // 常智禹
{ studentIdx: 2, roomIdx: 4, sharedApr: 48.35, sharedMay: 67.61 }, // 陈昊天
];
for (const bd of billData) {
const student = this.cachedStudents[bd.studentIdx];
const room = this.cachedRooms[bd.roomIdx];
if (!student || !room) continue;
// April bill
const billApr = await this.billRepo.save({
studentId: student.id,
periodStart: '2026-04-01',
periodEnd: '2026-04-30',
sharedAmount: bd.sharedApr,
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 },
{ billId: billApr.id, roomId: room.id, expenseType: 'water', description: '水费分摊', days: 30, studentAmount: Math.round(bd.sharedApr * 0.18 * 100) / 100 },
]);
// May bill
const billMay = await this.billRepo.save({
studentId: student.id,
periodStart: '2026-05-01',
periodEnd: '2026-05-31',
sharedAmount: bd.sharedMay,
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 },
{ billId: billMay.id, roomId: room.id, expenseType: 'water', description: '水费分摊', days: 31, studentAmount: Math.round(bd.sharedMay * 0.18 * 100) / 100 },
]);
}
this.logger.log(' ✓ bills + items (April & May 2026)');
}
// ── 5a: attendance ───────────────────────────────────
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 }> = [];
for (const cls of this.cachedClasses) {
const classStudents = await this.classStudentRepo.find({ where: { classId: cls.id } });
for (const cs of classStudents) {
// Last 10 weekdays
let d = 0;
let count = 0;
while (count < 10) {
const date = new Date();
date.setDate(date.getDate() - d);
const dow = date.getDay();
if (dow !== 0 && dow !== 6) {
records.push({
studentId: cs.studentId,
classId: cls.id,
attendanceDate: date.toISOString().slice(0, 10),
session: 'am',
status: pick(statuses),
source: 'manual',
departmentId: this.deptId,
});
count++;
}
d++;
}
}
}
await this.attendanceRepo.save(records);
this.logger.log(`${records.length} attendance records`);
}
// ── 5b: classroom rentals ────────────────────────────
private async seedClassroomRentals(): Promise<void> {
if (this.cachedClassrooms.length > 0 && this.cachedTenants.length > 0) {
await this.rentalRepo.save({
classroomId: this.cachedClassrooms[0].id,
tenantId: this.cachedTenants[0].id,
startDate: '2026-05-14',
endDate: '2026-06-30',
totalAmount: 30000,
status: 'active',
departmentId: this.deptId,
});
this.logger.log(' ✓ 1 classroom rental');
}
}
// ── 6a: student profiles ─────────────────────────────
private async seedProfiles(): Promise<void> {
const colleges = ['北京大学', '清华大学', '复旦大学', '浙江大学', '南京大学'];
const profiles = this.cachedStudents.slice(0, 10).map((s) => ({
studentId: s.id,
targetCollege: pick(colleges),
targetMajor: pick(['计算机科学', '数学', '物理学', '经济学']),
subjectDirection: pick(['理科', '文科']),
grade: '高三',
campusLocation: '主校区',
departmentId: this.deptId,
}));
await this.profileRepo.save(profiles);
this.logger.log(`${profiles.length} student profiles`);
}
// ── 6b: student enrollments ──────────────────────────
private async seedEnrollments(): Promise<void> {
const enrollments = this.cachedStudents.slice(0, 15).map((s) => ({
studentId: s.id,
courseCategory: pick(['文化课', '专业课', '集训']),
classType: pick(['全日制', '周末班']),
className: pick(['26定向班', '26尊享班', '续住1班']),
headTeacher: '张班主任',
startDate: '2026-04-01',
endDate: '2026-08-31',
status: 'active',
departmentId: this.deptId,
}));
await this.enrollmentRepo.save(enrollments);
this.logger.log(`${enrollments.length} enrollments`);
}
// ── 6c: exam scores ──────────────────────────────────
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 }> = [];
for (const s of this.cachedStudents.slice(0, 10)) {
for (const exam of exams) {
for (const subj of subjects) {
scores.push({
studentId: s.id,
examType: 'exam',
examName: exam,
subject: subj,
score: randInt(50, 100),
examDate: `2026-0${randInt(4, 6)}-${String(randInt(1, 28)).padStart(2, '0')}`,
departmentId: this.deptId,
});
}
}
}
await this.examScoreRepo.save(scores);
this.logger.log(`${scores.length} exam scores`);
}
// ── 6d: learning records ────────────────────────────
private async seedLearningRecords(): Promise<void> {
const types = ['跟进记录', '家长沟通', '学习反馈', '教学建议'];
const records = this.cachedStudents.slice(0, 12).map((s) => ({
studentId: s.id,
recordDate: `2026-0${randInt(5, 6)}-${String(randInt(1, 28)).padStart(2, '0')}`,
recordType: pick(types),
content: `学习状态:${pick(['良好', '一般', '需加强'])}`,
followUpMethod: pick(['电话', '微信', '面谈']),
nextStep: '继续跟进',
departmentId: this.deptId,
}));
await this.learningRecordRepo.save(records);
this.logger.log(`${records.length} learning records`);
}
}

View File

@@ -0,0 +1,58 @@
import { Module, OnModuleInit, Logger } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SeedDevService } from './seed-dev.service';
import {
Student, Room, Occupancy, RoomExpense,
Bill, BillItem, User, Deposit,
Classroom, Tenant, ClassroomRental, Permission, Role,
Class, ClassStudent, ClassTeacher, ClassSchedule,
AttendanceRecord, Department, UserDepartment,
StudentProfile, StudentEnrollment, ExamScore, LearningRecord,
ExpenseType,
} from '../entities';
const SEED_ENTITIES = [
Department, User, Role, Permission, UserDepartment,
Tenant, Student, Room, Classroom, Occupancy,
RoomExpense, ExpenseType, Class,
ClassStudent, ClassTeacher, ClassSchedule, Bill, BillItem,
Deposit, AttendanceRecord,
ClassroomRental, StudentProfile, StudentEnrollment,
ExamScore, LearningRecord,
];
@Module({
imports: [TypeOrmModule.forFeature(SEED_ENTITIES)],
providers: [SeedDevService],
})
export class SeedModule implements OnModuleInit {
private readonly logger = new Logger(SeedModule.name);
constructor(private readonly seedService: SeedDevService) {}
async onModuleInit() {
const enabled = process.env['SEED_DEV'] === 'true' || process.env['NODE_ENV'] === 'development';
const skip = process.env['SEED_DEV_SKIP'] === 'true';
if (!enabled || skip) {
this.logger.log(
`Seed skipped: SEED_DEV=${process.env['SEED_DEV']}, NODE_ENV=${process.env['NODE_ENV']}, SKIP=${skip}`,
);
return;
}
const studentCount = await this.seedService.getStudentCount();
if (studentCount > 0) {
this.logger.log(`Seed skipped: ${studentCount} students already exist`);
return;
}
this.logger.log('Starting mock data seeding...');
try {
await this.seedService.seed();
this.logger.log('Mock data seeding completed successfully');
} catch (err) {
this.logger.error('Mock data seeding failed', err instanceof Error ? err.stack : String(err));
}
}
}

View File

@@ -4,6 +4,10 @@ export class CreateStudentDto {
@IsString()
name: string;
@IsOptional()
@IsString()
studentNo?: string;
@IsOptional()
@IsString()
phone?: string;
@@ -53,6 +57,10 @@ export class UpdateStudentDto {
@IsString()
name?: string;
@IsOptional()
@IsString()
studentNo?: string;
@IsOptional()
@IsString()
phone?: string;

View File

@@ -1,4 +1,4 @@
import { Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
import { BadRequestException, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import { SyncService } from './sync.service';
@@ -11,8 +11,12 @@ export class SyncController {
@Post('trigger')
@RequirePermission('sync:trigger')
async triggerSync(@Query('platform') platform?: SyncPlatform) {
const logs = await this.syncService.triggerSync(platform);
async triggerSync(
@Query('platform') platform?: SyncPlatform,
@Query('rootDeptId') rootDeptId?: string,
) {
const rootId = rootDeptId ? this.parseRootDeptId(rootDeptId) : 1;
const logs = await this.syncService.triggerSync(platform, rootId);
return { synced: logs.length, logs };
}
@@ -27,6 +31,15 @@ export class SyncController {
};
}
/** 获取钉钉组织部门树,供前端选择同步起点 */
@Get('dingtalk/org-tree')
@RequirePermission('sync:read')
async getDingTalkOrgTree(@Query('rootDeptId') rootDeptId?: string) {
const rootId = rootDeptId ? this.parseRootDeptId(rootDeptId) : 1;
const tree = await this.syncService.getDingTalkOrgTree(rootId);
return { success: true, data: tree };
}
@Get('logs')
@RequirePermission('sync:read')
async getLogs(
@@ -35,4 +48,12 @@ export class SyncController {
) {
return this.syncService.getLogs(platform, limit ? Number(limit) : 50);
}
private parseRootDeptId(rootDeptId: string): number {
const parsed = parseInt(rootDeptId, 10);
if (isNaN(parsed)) {
throw new BadRequestException('rootDeptId must be a valid integer');
}
return parsed;
}
}

View File

@@ -33,7 +33,7 @@ export class SyncService {
}
// ── Sync DingTalk ──
async syncDingTalk(): Promise<SyncLog> {
async syncDingTalk(rootDeptId = 1): Promise<SyncLog> {
const platform: SyncPlatform = 'dingtalk';
const syncType = await this.determineSyncType(platform);
@@ -45,7 +45,7 @@ export class SyncService {
// ── Call existing integration APIs ──
// Integration hooks — extend here to call DingTalk APIs with lastSyncAt
const recordsCount = await this.performDingTalkSync(lastSyncAt);
const recordsCount = await this.performDingTalkSync(lastSyncAt, rootDeptId);
await this.updateLastSyncAt(platform);
await this.finishSyncLog(log, 'success', recordsCount);
@@ -87,10 +87,15 @@ export class SyncService {
}
// ── Manual trigger ──
async triggerSync(platform?: SyncPlatform): Promise<SyncLog[]> {
if (platform === 'dingtalk') return [await this.syncDingTalk()];
async triggerSync(platform?: SyncPlatform, rootDeptId = 1): Promise<SyncLog[]> {
if (platform === 'dingtalk') return [await this.syncDingTalk(rootDeptId)];
if (platform === 'wecom') return [await this.syncWeCom()];
return [await this.syncDingTalk(), await this.syncWeCom()];
return [await this.syncDingTalk(rootDeptId), await this.syncWeCom()];
}
/** 获取钉钉组织部门树,供前端选择同步起点 */
async getDingTalkOrgTree(rootDeptId = 1) {
return this.dingTalkService.fetchOrgTree(rootDeptId);
}
// ── Sync log queries ──
@@ -154,9 +159,9 @@ export class SyncService {
await this.syncLogRepo.save(log);
}
private async performDingTalkSync(lastSyncAt: Date | null): Promise<number> {
private async performDingTalkSync(lastSyncAt: Date | null, rootDeptId = 1): Promise<number> {
// Stage 1: Sync departments and users
const result = await this.dingTalkService.syncAll();
const result = await this.dingTalkService.syncAll(rootDeptId);
let total = result.deptCount + result.userCount;
// Stage 2: Import attendance data (last 7 days or since last sync)
@@ -175,7 +180,7 @@ export class SyncService {
this.logger.log(`Importing DingTalk attendance: ${start} ~ ${end}`);
const mappings = await this.mappingRepo.find();
const userIds = mappings.map((m) => m.dingUserId).filter(Boolean);
const userIds = mappings.map((m) => m.dingUserId);
const importResult = await this.attendanceImportService.importFromDingTalk({
startDate: start,
endDate: end,