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:
2026-07-18 12:54:10 +00:00
parent 92d303ed01
commit 375c7ec60b
64 changed files with 5169 additions and 2404 deletions

View File

@@ -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) {