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:
258
apps/server/src/attendance/attendance-import.service.ts
Normal file
258
apps/server/src/attendance/attendance-import.service.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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.
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
66
apps/server/src/attendance/dto/dingtalk-import.dto.ts
Normal file
66
apps/server/src/attendance/dto/dingtalk-import.dto.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user