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

@@ -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 };