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

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