feat: refine admin forms, attendance and finance workflows
Squash merge PR #23. Included changes: - complete occupancy check-in required fields/default payload - improve responsive admin management pages - fix attendance edge cases and attendance period config - refine wallet/finance-related workflow handling Checks: - npm run typecheck -w apps/admin - npm run typecheck -w apps/server
This commit is contained in:
@@ -113,6 +113,8 @@ export class AttendanceSettlementService {
|
||||
const userIds = await this.attendanceService.getTeacherClassDingUserIds(
|
||||
schedule.teacherId,
|
||||
schedule.classId,
|
||||
false,
|
||||
lessonDate,
|
||||
);
|
||||
const importRange = this.attendanceService.getLessonAttendanceImportDateRange(
|
||||
schedule,
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
AttendanceSummaryQueryDto,
|
||||
AttendanceCalendarQueryDto,
|
||||
QueryAttendanceRecordsDto,
|
||||
AttendanceScheduleOptionsQueryDto,
|
||||
QueryDingRawDto,
|
||||
MatchDingRecordDto,
|
||||
AttendanceReportQueryDto,
|
||||
@@ -33,6 +34,8 @@ import {
|
||||
GenerateFromSchedulesDto,
|
||||
LessonAttendanceQueryDto,
|
||||
StartLessonAttendanceDto,
|
||||
SaveAttendancePeriodConfigsDto,
|
||||
RefreshDingTalkAttendanceDto,
|
||||
} from './dto/attendance.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
@@ -92,6 +95,45 @@ export class AttendanceController {
|
||||
return this.service.assertClassAccess(req.user.id, classId, this.canManageAllAttendance(req));
|
||||
}
|
||||
|
||||
|
||||
@Get('attendance-period-configs')
|
||||
@RequirePermission('attendance:view')
|
||||
getAttendancePeriodConfigs() {
|
||||
return this.service.getAttendancePeriodConfigs();
|
||||
}
|
||||
|
||||
@Put('attendance-period-configs')
|
||||
@RequirePermission('attendance:edit')
|
||||
async saveAttendancePeriodConfigs(
|
||||
@Body() dto: SaveAttendancePeriodConfigsDto,
|
||||
@Request() req: { user: RequestUser },
|
||||
) {
|
||||
const result = await this.service.saveAttendancePeriodConfigs(dto);
|
||||
await this.logService.log({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
module: '考勤管理',
|
||||
action: '保存考勤时段配置',
|
||||
targetType: 'attendancePeriodConfig',
|
||||
detail: dto.periods.map((item) => `${item.label}:${item.startTime}-${item.endTime}`).join(';'),
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('attendance-period-configs/reset')
|
||||
@RequirePermission('attendance:edit')
|
||||
async resetAttendancePeriodConfigs(@Request() req: { user: RequestUser }) {
|
||||
const result = await this.service.resetAttendancePeriodConfigs();
|
||||
await this.logService.log({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
module: '考勤管理',
|
||||
action: '重置考勤时段配置',
|
||||
targetType: 'attendancePeriodConfig',
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get('attendance-lessons/schedules/:scheduleId')
|
||||
@RequirePermission('attendance:view')
|
||||
async getLessonAttendance(
|
||||
@@ -117,6 +159,7 @@ export class AttendanceController {
|
||||
req.user.id,
|
||||
schedule.schedule.classId!,
|
||||
this.canManageAllAttendance(req),
|
||||
dto.date,
|
||||
);
|
||||
const importRange = this.service.getLessonAttendanceImportDateRange(
|
||||
schedule.schedule,
|
||||
@@ -128,6 +171,9 @@ export class AttendanceController {
|
||||
autoMatch: true,
|
||||
userId: req.user.id,
|
||||
});
|
||||
if (!importResult.success || importResult.errors.length > 0) {
|
||||
throw new BadRequestException(importResult.errors.join('; ') || '钉钉考勤拉取失败');
|
||||
}
|
||||
const result = await this.service.createLessonAttendanceFromDingTalk(
|
||||
scheduleId,
|
||||
dto.date,
|
||||
@@ -166,6 +212,84 @@ export class AttendanceController {
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@Get('attendance-records/dingtalk-sync-status')
|
||||
@RequirePermission('attendance:view')
|
||||
async getDingTalkSyncStatus() {
|
||||
const latest = await this.logService.findLatestDingTalkAttendancePull();
|
||||
return {
|
||||
lastPulledAt: latest?.createdAt ?? null,
|
||||
action: latest?.action ?? null,
|
||||
username: latest?.username ?? null,
|
||||
detail: latest?.detail ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@Post('attendance-records/refresh-dingtalk')
|
||||
@RequirePermission('attendance:create')
|
||||
async refreshDingTalkAttendance(
|
||||
@Body() dto: RefreshDingTalkAttendanceDto,
|
||||
@Request() req: { user: RequestUser },
|
||||
) {
|
||||
if (dto.date > this.getTodayDateOnly()) {
|
||||
throw new BadRequestException('不能查看或刷新未来日期的考勤');
|
||||
}
|
||||
if (dto.classId) await this.assertClassAccess(req, dto.classId);
|
||||
const schedules = await this.service.getRefreshableSchedules(
|
||||
dto.date,
|
||||
dto.classId,
|
||||
dto.session,
|
||||
await this.getAccessibleClassIds(req),
|
||||
);
|
||||
let refreshed = 0;
|
||||
let imported = 0;
|
||||
let matched = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const schedule of schedules) {
|
||||
try {
|
||||
const importClassIds = await this.service.getTeacherClassDingUserIds(
|
||||
req.user.id,
|
||||
schedule.classId!,
|
||||
this.canManageAllAttendance(req),
|
||||
dto.date,
|
||||
);
|
||||
const importRange = this.service.getLessonAttendanceImportDateRange(schedule, dto.date);
|
||||
const importResult = await this.importService.importFromDingTalk({
|
||||
...importRange,
|
||||
userIds: importClassIds,
|
||||
autoMatch: true,
|
||||
userId: req.user.id,
|
||||
});
|
||||
if (!importResult.success || importResult.errors.length > 0) {
|
||||
errors.push(...importResult.errors);
|
||||
continue;
|
||||
}
|
||||
await this.service.createLessonAttendanceFromDingTalk(schedule.id, dto.date, req.user.id);
|
||||
refreshed += 1;
|
||||
imported += importResult.imported;
|
||||
matched += importResult.matched;
|
||||
} catch (error: unknown) {
|
||||
errors.push((error as { message?: string })?.message || `排课 ${schedule.id} 刷新失败`);
|
||||
}
|
||||
}
|
||||
|
||||
await this.logService.log({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
module: '考勤管理',
|
||||
action: '刷新钉钉考勤',
|
||||
targetType: 'attendanceRecord',
|
||||
detail: `日期${dto.date},排课${schedules.length}节,刷新${refreshed}节,钉钉新增${imported}条,匹配${matched}条${errors.length ? `,错误${errors.length}条` : ''}`,
|
||||
status: errors.length > 0 && refreshed === 0 ? 'failure' : 'success',
|
||||
});
|
||||
|
||||
if (schedules.length === 0) {
|
||||
return { refreshed, imported, matched, errors: ['当前条件下没有可刷新的课程'] };
|
||||
}
|
||||
return { refreshed, imported, matched, errors };
|
||||
}
|
||||
|
||||
// ── Batch create attendance records ──
|
||||
@Post('attendance-records/batch')
|
||||
@RequirePermission('attendance:create')
|
||||
@@ -277,6 +401,16 @@ export class AttendanceController {
|
||||
res.end();
|
||||
}
|
||||
|
||||
@Get('attendance-records/schedules')
|
||||
@RequirePermission('attendance:view')
|
||||
async getAttendanceScheduleOptions(
|
||||
@Query() query: AttendanceScheduleOptionsQueryDto,
|
||||
@Request() req: { user: RequestUser },
|
||||
) {
|
||||
await this.assertClassAccess(req, query.classId);
|
||||
return this.service.getScheduleOptionsForAttendance(query.classId, query.date);
|
||||
}
|
||||
|
||||
// ── List attendance records with filters ──
|
||||
@Get('attendance-records')
|
||||
@RequirePermission('attendance:view')
|
||||
@@ -522,6 +656,7 @@ export class AttendanceController {
|
||||
req.user.id,
|
||||
dto.classId,
|
||||
canManageAll,
|
||||
dto.start,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AttendanceRecord, AttendanceSession, AttendanceDevice, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping } from '../entities';
|
||||
import { AttendanceRecord, AttendanceSession, AttendanceDevice, AttendancePeriodConfig, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping } from '../entities';
|
||||
import { AttendanceService } from './attendance.service';
|
||||
import { AttendanceImportService } from './attendance-import.service';
|
||||
import { AttendanceSettlementService } from './attendance-settlement.service';
|
||||
@@ -10,7 +10,7 @@ import { IntegrationModule } from '../integration/integration.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([AttendanceRecord, AttendanceSession, AttendanceDevice, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping]),
|
||||
TypeOrmModule.forFeature([AttendanceRecord, AttendanceSession, AttendanceDevice, AttendancePeriodConfig, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping]),
|
||||
OperationLogsModule,
|
||||
IntegrationModule,
|
||||
],
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
AttendanceRecord,
|
||||
AttendanceSession,
|
||||
AttendanceDevice,
|
||||
AttendancePeriodConfig,
|
||||
DingAttendanceRaw,
|
||||
Class,
|
||||
Student,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
UpdateAttendanceRecordDto,
|
||||
GenerateAttendanceFromSchedulesDto,
|
||||
GenerateFromSchedulesDto,
|
||||
SaveAttendancePeriodConfigsDto,
|
||||
} from './dto/attendance.dto';
|
||||
|
||||
/** Keyed mutex serializing operations on the same attendance session. */
|
||||
@@ -69,11 +71,20 @@ export class AttendanceService {
|
||||
private attendanceSessionRepo: Repository<AttendanceSession>,
|
||||
@InjectRepository(AttendanceDevice)
|
||||
private attendanceDeviceRepo: Repository<AttendanceDevice>,
|
||||
@InjectRepository(AttendancePeriodConfig)
|
||||
private attendancePeriodConfigRepo: Repository<AttendancePeriodConfig>,
|
||||
private dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
private sessionMutex = new SessionMutex();
|
||||
|
||||
private readonly defaultAttendancePeriods = [
|
||||
{ periodKey: 'morning_reading', label: '早自习', startTime: '07:30', endTime: '08:30', sortOrder: 1 },
|
||||
{ periodKey: 'morning', label: '早课', startTime: '09:00', endTime: '12:00', sortOrder: 2 },
|
||||
{ periodKey: 'afternoon', label: '晚课', startTime: '14:00', endTime: '17:00', sortOrder: 3 },
|
||||
{ periodKey: 'evening_study', label: '晚自习', startTime: '18:30', endTime: '21:00', sortOrder: 4 },
|
||||
] as const;
|
||||
|
||||
private formatDeviceDetail(device: AttendanceDevice): string {
|
||||
const classroomName = device.classroom?.name;
|
||||
return classroomName ? `${device.deviceName} · ${classroomName}` : device.deviceName;
|
||||
@@ -144,6 +155,28 @@ export class AttendanceService {
|
||||
if (!assignment) throw new BadRequestException('只能访问自己任教班级的考勤');
|
||||
}
|
||||
|
||||
private isClassStudentActiveOnDate(classStudent: Pick<ClassStudent, 'joinDate' | 'leaveDate' | 'status'>, lessonDate: string): boolean {
|
||||
const status = classStudent.status ?? 'active';
|
||||
if (!['active', 'left'].includes(status)) return false;
|
||||
if (classStudent.joinDate && classStudent.joinDate > lessonDate) return false;
|
||||
if (classStudent.leaveDate && classStudent.leaveDate < lessonDate) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private async getClassStudentsForLesson(
|
||||
classId: number,
|
||||
lessonDate: string,
|
||||
relations: string[] = [],
|
||||
): Promise<ClassStudent[]> {
|
||||
const classStudents = await this.classStudentRepo.find({
|
||||
where: { classId, status: In(['active', 'left']) },
|
||||
relations,
|
||||
});
|
||||
return classStudents.filter((classStudent) =>
|
||||
this.isClassStudentActiveOnDate(classStudent, lessonDate),
|
||||
);
|
||||
}
|
||||
|
||||
/** List classes the current user may select for DingTalk attendance import. */
|
||||
async getImportableClasses(userId: number, isSuperAdmin = false) {
|
||||
if (isSuperAdmin) {
|
||||
@@ -174,6 +207,7 @@ export class AttendanceService {
|
||||
userId: number,
|
||||
classId: number,
|
||||
isSuperAdmin = false,
|
||||
lessonDate?: string,
|
||||
): Promise<string[]> {
|
||||
if (!isSuperAdmin) {
|
||||
const assignment = await this.classTeacherRepo.findOne({
|
||||
@@ -187,9 +221,11 @@ export class AttendanceService {
|
||||
if (!cls) throw new NotFoundException(`Class ${classId} not found`);
|
||||
}
|
||||
|
||||
const classStudents = await this.classStudentRepo.find({
|
||||
where: { classId, status: 'active' },
|
||||
});
|
||||
const classStudents = lessonDate
|
||||
? await this.getClassStudentsForLesson(classId, lessonDate)
|
||||
: await this.classStudentRepo.find({
|
||||
where: { classId, status: 'active' },
|
||||
});
|
||||
const studentIds = [...new Set(classStudents.map((item) => item.studentId))];
|
||||
if (studentIds.length === 0) {
|
||||
throw new BadRequestException('该班级暂无在读学生');
|
||||
@@ -375,15 +411,17 @@ export class AttendanceService {
|
||||
where: { attendanceSessionId: existing.id },
|
||||
order: { studentId: 'ASC' },
|
||||
});
|
||||
const classStudents = await this.classStudentRepo.find({
|
||||
where: { classId: schedule.classId!, status: 'active' },
|
||||
relations: ['student'],
|
||||
});
|
||||
const classStudents = await this.getClassStudentsForLesson(
|
||||
schedule.classId!,
|
||||
lessonDate,
|
||||
['student'],
|
||||
);
|
||||
const studentsById = new Map(
|
||||
classStudents.map((classStudent) => [classStudent.studentId, classStudent.student]),
|
||||
);
|
||||
const existingStudentIds = new Set(existingRecords.map((record) => record.studentId));
|
||||
|
||||
const lessonSessionKey = this.mapLessonScheduleTimeToSession(schedule.startTime);
|
||||
const updatedRecords = existingRecords.map((record) => {
|
||||
record.student = studentsById.get(record.studentId)!;
|
||||
// Preserve manual corrections only while the lesson is still in progress.
|
||||
@@ -422,7 +460,7 @@ export class AttendanceService {
|
||||
scheduleId,
|
||||
attendanceSessionId: existing.id,
|
||||
attendanceDate: lessonDate,
|
||||
session: this.mapScheduleTimeToSession(schedule.startTime),
|
||||
session: lessonSessionKey,
|
||||
status: this.mapDingTalkStatus(raw, finalize),
|
||||
source: 'dingtalk',
|
||||
...this.getLessonPunchMetadata(
|
||||
@@ -456,10 +494,11 @@ export class AttendanceService {
|
||||
const recordRepo = manager.getRepository(AttendanceRecord);
|
||||
const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, schedule, lessonDate);
|
||||
|
||||
const classStudents = await this.classStudentRepo.find({
|
||||
where: { classId: schedule.classId!, status: 'active' },
|
||||
relations: ['student'],
|
||||
});
|
||||
const classStudents = await this.getClassStudentsForLesson(
|
||||
schedule.classId!,
|
||||
lessonDate,
|
||||
['student'],
|
||||
);
|
||||
if (classStudents.length === 0) throw new BadRequestException('该班级暂无在读学生');
|
||||
|
||||
let session: AttendanceSession;
|
||||
@@ -495,6 +534,7 @@ export class AttendanceService {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const lessonSessionKey = this.mapLessonScheduleTimeToSession(schedule.startTime);
|
||||
const records = classStudents.map((classStudent) => {
|
||||
const raw = this.selectDingTalkRecordsForLesson(
|
||||
rawByStudent.get(classStudent.studentId) ?? [],
|
||||
@@ -508,7 +548,7 @@ export class AttendanceService {
|
||||
scheduleId,
|
||||
attendanceSessionId: session.id,
|
||||
attendanceDate: lessonDate,
|
||||
session: this.mapScheduleTimeToSession(schedule.startTime),
|
||||
session: lessonSessionKey,
|
||||
status: this.mapDingTalkStatus(raw, finalize),
|
||||
source: 'dingtalk',
|
||||
...this.getLessonPunchMetadata(
|
||||
@@ -539,9 +579,7 @@ export class AttendanceService {
|
||||
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
|
||||
lessonDate: string,
|
||||
): Promise<Map<number, DingAttendanceRaw[]>> {
|
||||
const classStudents = await this.classStudentRepo.find({
|
||||
where: { classId, status: 'active' },
|
||||
});
|
||||
const classStudents = await this.getClassStudentsForLesson(classId, lessonDate);
|
||||
if (classStudents.length === 0) return new Map();
|
||||
const studentIds = classStudents.map((cs) => cs.studentId);
|
||||
const window = this.getLessonAttendanceWindow(schedule, lessonDate);
|
||||
@@ -654,7 +692,7 @@ export class AttendanceService {
|
||||
});
|
||||
|
||||
const classStudents = await this.classStudentRepo.find({
|
||||
where: { classId, status: 'active' },
|
||||
where: { classId, status: In(['active', 'left']) },
|
||||
relations: ['student'],
|
||||
});
|
||||
|
||||
@@ -679,8 +717,11 @@ export class AttendanceService {
|
||||
if (sched.weekDay !== weekDay) continue;
|
||||
if (dateStr < sched.startDate || dateStr > sched.endDate) continue;
|
||||
|
||||
const session = this.mapScheduleTimeToSession(sched.startTime);
|
||||
for (const cs of classStudents) {
|
||||
const session = await this.mapScheduleTimeToSession(sched.startTime);
|
||||
const classStudentsForDate = classStudents.filter((cs) =>
|
||||
this.isClassStudentActiveOnDate(cs, dateStr),
|
||||
);
|
||||
for (const cs of classStudentsForDate) {
|
||||
const key = `${cs.studentId}|${dateStr}|${session}`;
|
||||
if (existingKeys.has(key)) continue;
|
||||
|
||||
@@ -754,7 +795,97 @@ export class AttendanceService {
|
||||
return shifted.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
private mapScheduleTimeToSession(startTime: string): string {
|
||||
private async ensureAttendancePeriodConfigs() {
|
||||
const count = await this.attendancePeriodConfigRepo.count();
|
||||
if (count === 0) {
|
||||
await this.attendancePeriodConfigRepo.save(
|
||||
this.defaultAttendancePeriods.map((period) => this.attendancePeriodConfigRepo.create({
|
||||
...period,
|
||||
enabled: true,
|
||||
})),
|
||||
);
|
||||
}
|
||||
return this.attendancePeriodConfigRepo.find({ order: { sortOrder: 'ASC', id: 'ASC' } });
|
||||
}
|
||||
|
||||
async getAttendancePeriodConfigs() {
|
||||
return this.ensureAttendancePeriodConfigs();
|
||||
}
|
||||
|
||||
async getRefreshableSchedules(date: string, classId?: number, session?: string, accessibleClassIds?: number[]) {
|
||||
const parsedDate = new Date(`${date}T00:00:00`);
|
||||
if (Number.isNaN(parsedDate.getTime())) throw new BadRequestException('无效日期');
|
||||
const weekDay = parsedDate.getDay() === 0 ? 7 : parsedDate.getDay();
|
||||
const qb = this.scheduleRepo
|
||||
.createQueryBuilder('schedule')
|
||||
.where('schedule.scheduleType = :scheduleType', { scheduleType: ScheduleType.INTERNAL })
|
||||
.andWhere('schedule.status = :status', { status: 'active' })
|
||||
.andWhere('schedule.classId IS NOT NULL')
|
||||
.andWhere('schedule.weekDay = :weekDay', { weekDay })
|
||||
.andWhere('schedule.startDate <= :date', { date })
|
||||
.andWhere('schedule.endDate >= :date', { date });
|
||||
|
||||
if (classId) {
|
||||
qb.andWhere('schedule.classId = :classId', { classId });
|
||||
} else if (accessibleClassIds) {
|
||||
if (accessibleClassIds.length === 0) return [];
|
||||
qb.andWhere('schedule.classId IN (:...accessibleClassIds)', { accessibleClassIds });
|
||||
}
|
||||
|
||||
const schedules = await qb.orderBy('schedule.startTime', 'ASC').getMany();
|
||||
if (!session) return schedules;
|
||||
|
||||
const matchedSchedules: ClassSchedule[] = [];
|
||||
for (const schedule of schedules) {
|
||||
if ((await this.mapScheduleTimeToSession(schedule.startTime)) === session) {
|
||||
matchedSchedules.push(schedule);
|
||||
}
|
||||
}
|
||||
return matchedSchedules;
|
||||
}
|
||||
|
||||
async saveAttendancePeriodConfigs(dto: SaveAttendancePeriodConfigsDto) {
|
||||
const seen = new Set<string>();
|
||||
const normalized = dto.periods.map((period, index) => {
|
||||
const periodKey = period.periodKey.trim();
|
||||
const label = period.label.trim();
|
||||
if (!periodKey || !label) throw new BadRequestException('时段标识和名称不能为空');
|
||||
if (seen.has(periodKey)) throw new BadRequestException(`时段标识 ${periodKey} 重复`);
|
||||
seen.add(periodKey);
|
||||
if (this.toMinutes(period.endTime) <= this.toMinutes(period.startTime)) {
|
||||
throw new BadRequestException(`${label} 的结束时间必须晚于开始时间`);
|
||||
}
|
||||
return {
|
||||
periodKey,
|
||||
label,
|
||||
startTime: period.startTime,
|
||||
endTime: period.endTime,
|
||||
sortOrder: period.sortOrder ?? index + 1,
|
||||
enabled: period.enabled ?? true,
|
||||
};
|
||||
}).sort((left, right) => left.sortOrder - right.sortOrder);
|
||||
|
||||
for (let index = 1; index < normalized.length; index += 1) {
|
||||
const previous = normalized[index - 1];
|
||||
const current = normalized[index];
|
||||
if (previous.enabled && current.enabled && this.toMinutes(current.startTime) < this.toMinutes(previous.endTime)) {
|
||||
throw new BadRequestException(`${previous.label} 和 ${current.label} 时间段不能重叠`);
|
||||
}
|
||||
}
|
||||
|
||||
await this.attendancePeriodConfigRepo.clear();
|
||||
await this.attendancePeriodConfigRepo.save(
|
||||
normalized.map((period) => this.attendancePeriodConfigRepo.create(period)),
|
||||
);
|
||||
return this.getAttendancePeriodConfigs();
|
||||
}
|
||||
|
||||
async resetAttendancePeriodConfigs() {
|
||||
await this.attendancePeriodConfigRepo.clear();
|
||||
return this.ensureAttendancePeriodConfigs();
|
||||
}
|
||||
|
||||
private mapLessonScheduleTimeToSession(startTime: string): string {
|
||||
const hour = parseInt(startTime.slice(0, 2), 10);
|
||||
if (hour < 8) return 'morning_reading';
|
||||
if (hour < 12) return 'morning';
|
||||
@@ -763,15 +894,31 @@ export class AttendanceService {
|
||||
return 'night_check';
|
||||
}
|
||||
|
||||
private async mapScheduleTimeToSession(startTime: string): Promise<string> {
|
||||
const startMinutes = this.toMinutes(startTime);
|
||||
const periods = (await this.ensureAttendancePeriodConfigs()).filter((period) => period.enabled);
|
||||
const matched = periods.find((period) => {
|
||||
const periodStart = this.toMinutes(period.startTime);
|
||||
const periodEnd = this.toMinutes(period.endTime);
|
||||
return startMinutes >= periodStart && startMinutes < periodEnd;
|
||||
});
|
||||
if (matched) return matched.periodKey;
|
||||
throw new BadRequestException(`课程开始时间 ${startTime} 未匹配到考勤时段,请先配置考勤时段`);
|
||||
}
|
||||
|
||||
|
||||
// ── Attendance summary ──
|
||||
async getSummary(query: AttendanceSummaryQueryDto, accessibleClassIds?: number[]) {
|
||||
const qb = this.attendanceRepo.createQueryBuilder('ar');
|
||||
if (query.classId) {
|
||||
qb.andWhere('ar.classId = :classId', { classId: query.classId });
|
||||
} else if (accessibleClassIds) {
|
||||
}
|
||||
if (query.scheduleId) {
|
||||
qb.andWhere('ar.scheduleId = :scheduleId', { scheduleId: query.scheduleId });
|
||||
}
|
||||
if (!query.classId && accessibleClassIds) {
|
||||
if (accessibleClassIds.length === 0)
|
||||
return { total: 0, present: 0, late: 0, absent: 0, leave: 0, presentRate: 0 };
|
||||
return { total: 0, present: 0, late: 0, absent: 0, leave: 0, pending: 0, presentRate: 0 };
|
||||
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
|
||||
}
|
||||
if (query.dateFrom) {
|
||||
@@ -780,6 +927,9 @@ export class AttendanceService {
|
||||
if (query.dateTo) {
|
||||
qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo });
|
||||
}
|
||||
if (query.session) {
|
||||
qb.andWhere('ar.session = :session', { session: query.session });
|
||||
}
|
||||
|
||||
const rows = await qb.getMany();
|
||||
|
||||
@@ -788,9 +938,10 @@ export class AttendanceService {
|
||||
const late = rows.filter((r) => r.status === 'late').length;
|
||||
const absent = rows.filter((r) => r.status === 'absent').length;
|
||||
const leave = rows.filter((r) => r.status === 'leave').length;
|
||||
const pending = rows.filter((r) => r.status === 'pending').length;
|
||||
const presentRate = total > 0 ? Number(((present / total) * 100).toFixed(1)) : 0;
|
||||
|
||||
return { total, present, late, absent, leave, presentRate };
|
||||
return { total, present, late, absent, leave, pending, presentRate };
|
||||
}
|
||||
|
||||
// ── Attendance calendar ──
|
||||
@@ -812,6 +963,25 @@ export class AttendanceService {
|
||||
return this.buildCalendar(classId, weekStart);
|
||||
}
|
||||
|
||||
private getWeekDayForDate(date: string): number {
|
||||
const day = new Date(`${date}T00:00:00+08:00`).getUTCDay();
|
||||
return day === 0 ? 7 : day;
|
||||
}
|
||||
|
||||
async getScheduleOptionsForAttendance(classId: number, date: string) {
|
||||
const weekDay = this.getWeekDayForDate(date);
|
||||
return this.scheduleRepo
|
||||
.createQueryBuilder('cs')
|
||||
.where('cs.classId = :classId', { classId })
|
||||
.andWhere('cs.weekDay = :weekDay', { weekDay })
|
||||
.andWhere('cs.startDate <= :date', { date })
|
||||
.andWhere('cs.endDate >= :date', { date })
|
||||
.andWhere('cs.status = :status', { status: 'active' })
|
||||
.orderBy('cs.startTime', 'ASC')
|
||||
.addOrderBy('cs.subject', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
private async buildCalendar(classId: number, weekStart: string) {
|
||||
// Compute weekEnd (Sunday = weekStart + 6 days)
|
||||
const start = new Date(weekStart);
|
||||
@@ -1018,6 +1188,7 @@ export class AttendanceService {
|
||||
async findAllForExport(
|
||||
query: {
|
||||
classId?: number;
|
||||
scheduleId?: number;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
session?: string;
|
||||
@@ -1029,6 +1200,9 @@ export class AttendanceService {
|
||||
const qb = this.attendanceRepo.createQueryBuilder('ar');
|
||||
|
||||
qb.leftJoinAndSelect('ar.student', 'student').leftJoinAndSelect('ar.class', 'class');
|
||||
if (query.scheduleId) {
|
||||
qb.andWhere('ar.scheduleId = :scheduleId', { scheduleId: query.scheduleId });
|
||||
}
|
||||
if (query.classId) {
|
||||
qb.andWhere('ar.classId = :classId', { classId: query.classId });
|
||||
} else if (accessibleClassIds) {
|
||||
|
||||
@@ -3,16 +3,53 @@ import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsInt,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
ValidateNested,
|
||||
IsNotEmpty,
|
||||
ArrayNotEmpty,
|
||||
Matches,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
|
||||
export class AttendancePeriodConfigItemDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
periodKey: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
label: string;
|
||||
|
||||
@IsString()
|
||||
@Matches(/^([01]\d|2[0-3]):[0-5]\d$/)
|
||||
startTime: string;
|
||||
|
||||
@IsString()
|
||||
@Matches(/^([01]\d|2[0-3]):[0-5]\d$/)
|
||||
endTime: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
sortOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export class SaveAttendancePeriodConfigsDto {
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AttendancePeriodConfigItemDto)
|
||||
periods: AttendancePeriodConfigItemDto[];
|
||||
}
|
||||
|
||||
export class AttendanceRecordItem {
|
||||
@IsInt()
|
||||
@IsNotEmpty()
|
||||
@@ -27,7 +64,6 @@ export class AttendanceRecordItem {
|
||||
attendanceDate: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['morning_reading', 'morning', 'afternoon', 'evening_study', 'night_check'])
|
||||
@IsNotEmpty()
|
||||
session: string;
|
||||
|
||||
@@ -59,6 +95,11 @@ export class AttendanceSummaryQueryDto {
|
||||
@Type(() => Number)
|
||||
classId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
scheduleId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dateFrom?: string;
|
||||
@@ -66,6 +107,26 @@ export class AttendanceSummaryQueryDto {
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dateTo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
session?: string;
|
||||
}
|
||||
|
||||
|
||||
export class RefreshDingTalkAttendanceDto {
|
||||
@IsDateString()
|
||||
@IsNotEmpty()
|
||||
date: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
classId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
session?: string;
|
||||
}
|
||||
|
||||
export class AttendanceCalendarQueryDto {
|
||||
@@ -112,6 +173,17 @@ export class QueryDingRawDto {
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export class AttendanceScheduleOptionsQueryDto {
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
@IsNotEmpty()
|
||||
classId: number;
|
||||
|
||||
@IsDateString()
|
||||
@IsNotEmpty()
|
||||
date: string;
|
||||
}
|
||||
|
||||
export class QueryAttendanceRecordsDto {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@@ -137,7 +209,7 @@ export class QueryAttendanceRecordsDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(['present', 'late', 'absent', 'leave'])
|
||||
@IsIn(['present', 'late', 'absent', 'leave', 'pending'])
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
Reference in New Issue
Block a user