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

@@ -31,6 +31,7 @@ import {
AttendanceRecord,
AttendanceSession,
AttendanceDevice,
AttendancePeriodConfig,
DingAttendanceRaw,
SyncLog,
SyncState,
@@ -128,6 +129,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
AttendanceRecord,
AttendanceSession,
AttendanceDevice,
AttendancePeriodConfig,
DingAttendanceRaw,
Notification,
StudentProfile,

View File

@@ -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,

View File

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

View File

@@ -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,
],

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

View File

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

View File

@@ -0,0 +1,33 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
@Entity('attendance_period_configs')
@Index(['periodKey'], { unique: true })
@Index(['sortOrder'])
export class AttendancePeriodConfig {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: 'period_key', type: 'varchar', length: 40 })
periodKey: string;
@Column({ type: 'varchar', length: 40 })
label: string;
@Column({ name: 'start_time', type: 'varchar', length: 5 })
startTime: string;
@Column({ name: 'end_time', type: 'varchar', length: 5 })
endTime: string;
@Column({ name: 'sort_order', type: 'integer', default: 0 })
sortOrder: number;
@Column({ type: 'boolean', default: true })
enabled: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@@ -23,6 +23,7 @@ export { ClassSchedule, ScheduleType } from './class-schedule.entity';
export { AttendanceRecord } from './attendance-record.entity';
export { AttendanceSession } from './attendance-session.entity';
export { AttendanceDevice, AttendanceDeviceStatus } from './attendance-device.entity';
export { AttendancePeriodConfig } from './attendance-period-config.entity';
export { DingAttendanceRaw } from './ding-attendance-raw.entity';
export { SyncLog } from './sync-log.entity';
export { SyncState } from './sync-state.entity';

View File

@@ -23,6 +23,17 @@ export class OperationLogsService {
return this.repo.save(entry);
}
async findLatestDingTalkAttendancePull() {
return this.repo
.createQueryBuilder('log')
.where('log.module = :module', { module: '考勤管理' })
.andWhere('log.action IN (:...actions)', {
actions: ['拉取钉钉课程考勤', '查看已拉取课程考勤', '钉钉考勤导入', '刷新钉钉考勤'],
})
.orderBy('log.createdAt', 'DESC')
.getOne();
}
async findAll(query?: {
module?: string;
userId?: number;

View File

@@ -143,6 +143,26 @@ const DEPRECATED_PERMISSION_CODES = [
const DEPRECATED_PERMISSION_CODE_SET = new Set<string>(DEPRECATED_PERMISSION_CODES);
function getChinaDateParts(date = new Date()): { date: string; weekDay: number } {
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
weekday: 'short',
})
.formatToParts(date)
.filter((part) => part.type !== 'literal')
.map((part) => [part.type, part.value]),
);
const weekDays: Record<string, number> = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7 };
return {
date: `${parts.year}-${parts.month}-${parts.day}`,
weekDay: weekDays[parts.weekday],
};
}
export const PRESET_ROLES: Array<{
name: string;
code: string;
@@ -684,11 +704,8 @@ export class RbacService {
subject: t.subject,
}));
// Get today's day of week (1=Monday, 7=Sunday)
const today = new Date();
const weekDay = today.getDay(); // 0=Sun → convert to 1-7
const adjustedWeekDay = weekDay === 0 ? 7 : weekDay;
const todayStr = today.toISOString().slice(0, 10);
// Get today's China business date and day of week (1=Monday, 7=Sunday)
const { date: todayStr, weekDay: adjustedWeekDay } = getChinaDateParts();
// Get today's schedules for assigned classes
const todaySchedules = await this.classScheduleRepo

View File

@@ -110,4 +110,14 @@ export class QueryStudentDto {
@Type(() => Number)
@IsInt()
organizationId?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
classId?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
teacherId?: number;
}

View File

@@ -175,6 +175,16 @@ export class StudentsController {
return this.service.getBasicLookups();
}
@Get('filter-lookups')
@RequirePermission('student:view')
async getFilterLookups(@Request() req: AuthenticatedRequest) {
const classIds = await this.service.getAccessibleClassIds(
req.user.id,
this.canManageAllStudents(req),
);
return this.service.getFilterLookups(classIds);
}
@Get()
@RequirePermission('student:view')
async findAll(
@@ -194,7 +204,7 @@ export class StudentsController {
@Get('export')
@RequirePermission('student:export')
async exportExcel(
@Query('includeArchived') includeArchived?: string,
@Query() query: QueryStudentDto,
@Res() res?: Response,
@Request() req?: any,
) {
@@ -202,10 +212,7 @@ export class StudentsController {
req.user.id,
this.canManageAllStudents(req),
);
const students = await this.service.findAll(
{ includeArchived: includeArchived === 'true' },
classIds,
);
const students = await this.service.findAll(query, classIds);
const workbook = new ExcelJS.Workbook();
const ws = workbook.addWorksheet('学生名单');
ws.columns = STUDENT_EXPORT_COLUMNS;

View File

@@ -1,6 +1,6 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Like, Not, In, FindOptionsWhere } from 'typeorm';
import { Repository, Like, Not, In, FindOptionsWhere, IsNull } from 'typeorm';
import { Student } from '../entities/student.entity';
import { Class } from '../entities/class.entity';
import { ClassStudent } from '../entities/class-student.entity';
@@ -41,6 +41,8 @@ export class StudentsService {
status?: string;
includeArchived?: boolean;
organizationId?: number | string;
classId?: number | string;
teacherId?: number | string;
},
accessibleClassIds?: number[],
) {
@@ -52,10 +54,28 @@ export class StudentsService {
} else if (!query?.includeArchived) {
where.status = Not(In(['archived', 'staff']));
}
if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return [];
let scopedClassIds = accessibleClassIds ? [...accessibleClassIds] : undefined;
if (query?.teacherId) {
const teacherAssignments = await this.classTeacherRepo.find({
where: { userId: Number(query.teacherId) },
});
const teacherClassIds = [...new Set(teacherAssignments.map((item) => item.classId))];
scopedClassIds = scopedClassIds
? scopedClassIds.filter((classId) => teacherClassIds.includes(classId))
: teacherClassIds;
}
if (query?.classId) {
const classId = Number(query.classId);
scopedClassIds = scopedClassIds
? scopedClassIds.filter((accessibleClassId) => accessibleClassId === classId)
: [classId];
}
if (scopedClassIds) {
if (scopedClassIds.length === 0) return [];
const classStudents = await this.classStudentRepo.find({
where: { classId: In(accessibleClassIds), status: 'active' },
where: { classId: In(scopedClassIds), status: 'active' },
});
const studentIds = [...new Set(classStudents.map((item) => item.studentId))];
if (studentIds.length === 0) return [];
@@ -64,6 +84,42 @@ export class StudentsService {
return this.repo.find({ where, order: { createdAt: 'DESC' }, relations: ['organization'] });
}
async getFilterLookups(accessibleClassIds?: number[]) {
if (accessibleClassIds && accessibleClassIds.length === 0) {
return { classes: [], teachers: [] };
}
const classWhere = accessibleClassIds
? { id: In(accessibleClassIds), isArchived: false }
: { isArchived: false };
const classes = await this.classRepo.find({
select: ['id', 'name', 'code'],
where: classWhere,
order: { name: 'ASC' },
});
const teacherWhere = accessibleClassIds
? { classId: In(accessibleClassIds) }
: { classId: In(classes.map((item) => item.id)), userId: Not(IsNull()) };
const assignments = classes.length
? await this.classTeacherRepo.find({ where: teacherWhere, relations: ['user'] })
: [];
const teacherMap = new Map<number, { id: number; name: string; username: string }>();
for (const assignment of assignments) {
if (!assignment.user || !assignment.user.isActive || assignment.user.isArchived) continue;
teacherMap.set(assignment.userId, {
id: assignment.userId,
name: assignment.user.name || assignment.user.username,
username: assignment.user.username,
});
}
return {
classes: classes.map((item) => ({ id: item.id, name: item.name, code: item.code })),
teachers: [...teacherMap.values()].sort((a, b) => a.name.localeCompare(b.name, 'zh-CN')),
};
}
async findOne(id: number) {
const student = await this.repo.findOne({
where: { id },

View File

@@ -13,8 +13,18 @@ export class WalletsController {
@Get()
@RequirePermission('wallet:view')
findAll(@Query('keyword') keyword?: string, @Query('debtOnly') debtOnly?: string) {
return this.service.findAll({ keyword, debtOnly: debtOnly === 'true' });
findAll(
@Query('keyword') keyword?: string,
@Query('debtOnly') debtOnly?: string,
@Query('roomType') roomType?: string,
) {
return this.service.findAll({ keyword, debtOnly: debtOnly === 'true', roomType });
}
@Get('room-types')
@RequirePermission('wallet:view')
findRoomTypes() {
return this.service.findRoomTypes();
}
@Get('transactions')

View File

@@ -4,12 +4,14 @@ import { Bill } from '../entities/bill.entity';
import { Student } from '../entities/student.entity';
import { StudentWallet } from '../entities/student-wallet.entity';
import { WalletTransaction } from '../entities/wallet-transaction.entity';
import { Room } from '../entities/room.entity';
import { Occupancy } from '../entities/occupancy.entity';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
import { WalletsController } from './wallets.controller';
import { WalletsService } from './wallets.service';
@Module({
imports: [TypeOrmModule.forFeature([StudentWallet, WalletTransaction, Student, Bill]), OperationLogsModule],
imports: [TypeOrmModule.forFeature([StudentWallet, WalletTransaction, Student, Bill, Room, Occupancy]), OperationLogsModule],
controllers: [WalletsController],
providers: [WalletsService],
exports: [WalletsService],

View File

@@ -8,6 +8,7 @@ import { WalletTransaction } from '../entities/wallet-transaction.entity';
import { In } from 'typeorm';
import { BatchChangeWalletBalanceDto, ChangeWalletBalanceDto } from './dto/wallet.dto';
import { FinancialOperationsService } from '../financial-operations/financial-operations.service';
import { Room } from '../entities/room.entity';
const money = (value: number | string | null | undefined) => Number(Number(value || 0).toFixed(2));
@@ -21,21 +22,41 @@ export class WalletsService {
private financialOperations?: FinancialOperationsService,
) {}
async findAll(query?: { keyword?: string; debtOnly?: boolean }) {
const students = await this.studentRepo
async findAll(query?: { keyword?: string; debtOnly?: boolean; roomType?: string }) {
const qb = this.studentRepo
.createQueryBuilder('student')
.where('student.status = :status', { status: 'active' })
.andWhere(
query?.keyword
? '(student.name LIKE :keyword OR student.studentNo LIKE :keyword)'
: '1 = 1',
query?.keyword ? { keyword: `%${query.keyword}%` } : {},
)
.orderBy('student.name', 'ASC')
.getMany();
if (!students.length) return [];
.leftJoin('student.occupancies', 'occupancy', 'occupancy.checkOutDate IS NULL')
.leftJoin('occupancy.room', 'room')
.where('student.status = :status', { status: 'active' });
const ids = students.map((student) => student.id);
if (query?.keyword) {
qb.andWhere('(student.name LIKE :keyword OR student.studentNo LIKE :keyword)', {
keyword: `%${query.keyword}%`,
});
}
if (query?.roomType) {
qb.andWhere('room.roomType = :roomType', { roomType: query.roomType });
}
const rows = await qb
.select([
'student.id AS studentId',
'student.name AS studentName',
'student.studentNo AS studentNo',
'room.roomType AS roomType',
'room.roomNumber AS roomNumber',
])
.orderBy('student.name', 'ASC')
.getRawMany<{
studentId: number;
studentName: string;
studentNo: string | null;
roomType: string | null;
roomNumber: string | null;
}>();
if (!rows.length) return [];
const ids = rows.map((row) => Number(row.studentId));
const wallets = await this.walletRepo.find({ where: { studentId: In(ids) } });
const bills = await this.dataSource.getRepository(Bill)
.createQueryBuilder('bill')
@@ -47,17 +68,34 @@ export class WalletsService {
.getRawMany<{ studentId: number; outstandingAmount: string }>();
const walletMap = new Map(wallets.map((wallet) => [wallet.studentId, wallet]));
const debtMap = new Map(bills.map((bill) => [Number(bill.studentId), money(bill.outstandingAmount)]));
return students
.map((student) => ({
studentId: student.id,
studentName: student.name,
studentNo: student.studentNo,
balance: money(walletMap.get(student.id)?.balance),
outstandingAmount: debtMap.get(student.id) || 0,
return rows
.map((row) => ({
studentId: Number(row.studentId),
studentName: row.studentName,
studentNo: row.studentNo || undefined,
roomType: row.roomType || undefined,
roomNumber: row.roomNumber || undefined,
balance: money(walletMap.get(Number(row.studentId))?.balance),
outstandingAmount: debtMap.get(Number(row.studentId)) || 0,
}))
.filter((row) => !query?.debtOnly || row.outstandingAmount > 0);
}
async findRoomTypes() {
const rows = await this.dataSource
.getRepository(Room)
.createQueryBuilder('room')
.innerJoin('room.occupancies', 'occupancy', 'occupancy.checkOutDate IS NULL')
.innerJoin('occupancy.student', 'student', 'student.status = :status', { status: 'active' })
.select('room.roomType', 'roomType')
.where('room.roomType IS NOT NULL')
.andWhere("room.roomType <> ''")
.distinct(true)
.orderBy('room.roomType', 'ASC')
.getRawMany<{ roomType: string }>();
return rows.map((row) => row.roomType);
}
async findTransactions(studentId: number) {
return this.transactionRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } });
}