fix permissions and teacher attendance workflows

This commit is contained in:
2026-07-10 20:40:52 +08:00
parent 247879f276
commit 8ed1682b90
95 changed files with 4745 additions and 1237 deletions

View File

@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm';
import { Subject, Observable } from 'rxjs';
@@ -115,7 +115,7 @@ export class AttendanceImportService {
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));
const entities = await Promise.all(batch.map((record) => this.mapToEntity(record)));
try {
await this.dingRawRepo.save(entities, { chunk: 50 });
imported += entities.length;
@@ -151,42 +151,92 @@ export class AttendanceImportService {
}
/**
* Paginate through DingTalk attendance API.
* The DingTalk API returns max 50 records per page.
* DingTalk requires userIds, accepts at most 50 users per request, and
* allows a maximum inclusive date range of 7 calendar days.
*/
private async fetchAllPages(params: {
startDate: string;
endDate: string;
userIds?: string[];
}): Promise<DingTalkAttendanceResult[]> {
const userIds = [...new Set((params.userIds ?? []).filter(Boolean))];
if (userIds.length === 0) {
throw new BadRequestException('拉取钉钉考勤必须指定人员范围');
}
if (params.startDate > params.endDate) {
throw new BadRequestException('开始日期不能晚于结束日期');
}
const allResults: DingTalkAttendanceResult[] = [];
const pageSize = 50;
let offset = 0;
let hasMore = true;
const userBatches = this.chunk(userIds, 50);
const dateRanges = this.splitDateRanges(params.startDate, params.endDate, 7);
const totalRequests = userBatches.length * dateRanges.length;
let completedRequests = 0;
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 {
for (const range of dateRanges) {
for (const users of userBatches) {
const batch = await this.dingTalkService.fetchAttendanceResults({
startDate: range.startDate,
endDate: range.endDate,
userIds: users,
});
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;
completedRequests++;
this.emit(
'fetching',
completedRequests,
totalRequests,
`已完成 ${completedRequests}/${totalRequests} 批,获取 ${allResults.length} 条记录`,
);
}
}
return allResults;
}
private chunk<T>(items: T[], size: number): T[][] {
const result: T[][] = [];
for (let index = 0; index < items.length; index += size) {
result.push(items.slice(index, index + size));
}
return result;
}
private splitDateRanges(
startDate: string,
endDate: string,
maxDays: number,
): Array<{ startDate: string; endDate: string }> {
const ranges: Array<{ startDate: string; endDate: string }> = [];
let cursor = this.parseDate(startDate);
const end = this.parseDate(endDate);
while (cursor.getTime() <= end.getTime()) {
const rangeEnd = new Date(cursor);
rangeEnd.setUTCDate(rangeEnd.getUTCDate() + maxDays - 1);
if (rangeEnd.getTime() > end.getTime()) rangeEnd.setTime(end.getTime());
ranges.push({
startDate: this.formatDate(cursor),
endDate: this.formatDate(rangeEnd),
});
cursor = new Date(rangeEnd);
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
return ranges;
}
private parseDate(value: string): Date {
const date = new Date(`${value}T00:00:00.000Z`);
if (Number.isNaN(date.getTime())) {
throw new BadRequestException(`无效日期: ${value}`);
}
return date;
}
private formatDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
/**
* Query which dingIds already exist to skip duplicates.
*/
@@ -206,10 +256,10 @@ export class AttendanceImportService {
/**
* Map a DingTalk API result to a DingAttendanceRaw entity.
*/
private mapToEntity(r: DingTalkAttendanceResult): DingAttendanceRaw {
private async mapToEntity(r: DingTalkAttendanceResult): Promise<DingAttendanceRaw> {
const entity = new DingAttendanceRaw();
entity.dingUserId = r.userId;
entity.userName = ''; // Will be filled from the result if available
entity.userName = r.userName || await this.resolveStudentName(r.userId);
entity.attendanceDate = r.workDate;
entity.dingId = r.checkId;
entity.attendanceType = r.checkType || 'OnDuty';
@@ -233,6 +283,15 @@ export class AttendanceImportService {
return entity;
}
private async resolveStudentName(dingUserId: string): Promise<string> {
const mapping = await this.studentDingMappingRepo.findOne({
where: { dingUserId },
});
if (!mapping) return '';
const student = await this.studentRepo.findOne({ where: { id: mapping.studentId } });
return student?.name || '';
}
/**
* Auto-match unmatched records to students via the dingUserId → userId mapping chain.
*