From e45da7f99824cc5d4087c8752e02dc7fd45ce905 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Tue, 14 Jul 2026 23:12:14 +0800 Subject: [PATCH] feat: improve attendance scheduling and API validation --- .../StudentProfileContent/index.tsx | 73 +++++- apps/admin/src/pages/Classes/detail.tsx | 2 + apps/admin/src/pages/Schedules/index.tsx | 30 ++- .../schedule-form.integration.test.ts | 5 + .../src/pages/Schedules/schedule-form.ts | 4 + apps/server/src/archive/archive.controller.ts | 91 +++---- .../src/archive/archive.service.spec.ts | 4 +- apps/server/src/archive/archive.service.ts | 10 +- .../attendance-settlement.service.spec.ts | 8 + .../attendance-settlement.service.ts | 7 +- .../attendance/attendance.controller.spec.ts | 3 + .../src/attendance/attendance.controller.ts | 56 +++-- .../attendance.lesson-session.spec.ts | 35 +++ .../src/attendance/attendance.service.ts | 73 ++++-- .../src/attendance/dto/attendance.dto.ts | 26 ++ apps/server/src/bills/bills.controller.ts | 37 +-- apps/server/src/bills/bills.module.ts | 2 + .../database/database-migrations.service.ts | 17 +- .../src/database/database-migrations.spec.ts | 22 ++ .../src/deposits/deposits.controller.ts | 51 ++-- .../src/deposits/deposits.refund.spec.ts | 16 +- apps/server/src/deposits/dto/deposit.dto.ts | 28 ++- .../src/entities/class-schedule.entity.ts | 4 + apps/server/src/expenses/dto/expense.dto.ts | 43 +++- .../src/expenses/expenses.controller.ts | 49 ++-- .../src/schedules/dto/schedule.dto.spec.ts | 15 ++ apps/server/src/schedules/dto/schedule.dto.ts | 12 + .../src/schedules/schedules.service.spec.ts | 36 ++- .../server/src/schedules/schedules.service.ts | 20 +- apps/server/src/students/dto/student.dto.ts | 30 ++- .../src/students/students.controller.ts | 41 ++-- .../src/sync/schedule-sync.service.spec.ts | 87 +++++++ apps/server/src/sync/schedule-sync.service.ts | 229 ++++++++++++------ 33 files changed, 869 insertions(+), 297 deletions(-) diff --git a/apps/admin/src/components/StudentProfileContent/index.tsx b/apps/admin/src/components/StudentProfileContent/index.tsx index 2fc1780..f66f5e6 100644 --- a/apps/admin/src/components/StudentProfileContent/index.tsx +++ b/apps/admin/src/components/StudentProfileContent/index.tsx @@ -105,6 +105,20 @@ interface AttachmentRecord { fileSize: number; } +interface AttendanceRecordItem { + id: number; + attendanceDate: string; + session: string; + status: string; + source?: string; + remark?: string | null; + punchTime?: string | null; + punchDeviceName?: string | null; + punchDeviceId?: string | null; + schedule?: { subject?: string } | null; + class?: { name?: string } | null; +} + interface StudentProfileAggregate { student: StudentInfo; profile: ProfileData | null; @@ -113,6 +127,7 @@ interface StudentProfileAggregate { learningRecords: LearningRecord[]; result: ResultData | null; attachments: AttachmentRecord[]; + attendances: AttendanceRecordItem[]; } export interface StudentProfileContentProps { @@ -178,6 +193,58 @@ const formatFileSize = (bytes: number): string => { // ---- Tab Components ---- +const ATTENDANCE_STATUS_MAP: Record = { + present: { text: '出勤', color: 'green' }, + late: { text: '迟到', color: 'orange' }, + absent: { text: '缺勤', color: 'red' }, + leave: { text: '请假', color: 'blue' }, + pending: { text: '待确认', color: 'default' }, +}; + +const SESSION_LABELS: Record = { + morning_reading: '早自习', + morning: '上午', + afternoon: '下午', + evening_study: '晚自习', + night_check: '晚寝', +}; + +const AttendanceTab: React.FC<{ data: AttendanceRecordItem[] }> = ({ data }) => { + const columns: ColumnsType = [ + { title: '日期', dataIndex: 'attendanceDate', width: 120 }, + { title: '课程', render: (_: unknown, record) => record.schedule?.subject || record.class?.name || '课程考勤' }, + { title: '时段', dataIndex: 'session', width: 100, render: (value: string) => SESSION_LABELS[value] || value || '-' }, + { + title: '结果', dataIndex: 'status', width: 90, + render: (value: string) => { + const meta = ATTENDANCE_STATUS_MAP[value] || { text: value || '-', color: 'default' }; + return {meta.text}; + }, + }, + { title: '打卡时间', dataIndex: 'punchTime', width: 170, render: (value?: string | null) => value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-' }, + { + title: '打卡设备', + render: (_: unknown, record) => { + const name = record.punchDeviceName?.trim(); + const id = record.punchDeviceId?.trim(); + if (name && id && name !== id) return `${name}(${id})`; + return name || id || (record.source === 'manual' ? '老师手动标记' : '-'); + }, + }, + { title: '备注', dataIndex: 'remark', render: (value?: string | null) => value || '-' }, + ]; + + return data.length > 0 ? ( + + columns={columns} + dataSource={data} + rowKey="id" + scroll={{ x: 900 }} + pagination={{ defaultPageSize: 15, showSizeChanger: true, pageSizeOptions: [15, 30, 50] }} + /> + ) : ; +}; + interface TabProps { studentId: number; onRefresh: () => void; @@ -779,7 +846,7 @@ const StudentProfileContent: React.FC = ({ const tabItems = useMemo(() => { if (!aggregateData) return []; - const { profile, enrollments, examScores, learningRecords, result, attachments } = aggregateData; + const { profile, enrollments, examScores, learningRecords, result, attachments, attendances } = aggregateData; return [ { key: 'profile', @@ -807,8 +874,8 @@ const StudentProfileContent: React.FC = ({ }, { key: 'attendance', - label: '出勤记录', - children: , + label: `出勤记录 (${attendances.length})`, + children: , }, { key: 'learning', diff --git a/apps/admin/src/pages/Classes/detail.tsx b/apps/admin/src/pages/Classes/detail.tsx index f5a89b0..a0ad7b0 100644 --- a/apps/admin/src/pages/Classes/detail.tsx +++ b/apps/admin/src/pages/Classes/detail.tsx @@ -39,6 +39,7 @@ interface ClassScheduleItem { weekDay: number; startTime: string; endTime: string; + attendanceAdvanceMinutes: number; startDate: string; endDate: string; subject: string; @@ -353,6 +354,7 @@ const ClassDetailPage: React.FC = () => { { title: '教室', dataIndex: 'classroomName', render: (v: string | null) => v || '-' }, { title: '星期', dataIndex: 'weekDay', render: (v: number) => WEEK_DAY_MAP[v] || v }, { title: '时间', render: (_: unknown, r: ClassScheduleItem) => `${r.startTime} - ${r.endTime}` }, + { title: '签到窗口', render: (_: unknown, r: ClassScheduleItem) => `课前 ${r.attendanceAdvanceMinutes ?? 30} 分钟至下课` }, { title: '日期范围', render: (_: unknown, r: ClassScheduleItem) => `${r.startDate} ~ ${r.endDate}` }, { title: '科目', dataIndex: 'subject' }, { title: '类型', dataIndex: 'scheduleType', render: (v: string) => SCHEDULE_TYPE_MAP[v] || v }, diff --git a/apps/admin/src/pages/Schedules/index.tsx b/apps/admin/src/pages/Schedules/index.tsx index c37e756..c60acc6 100644 --- a/apps/admin/src/pages/Schedules/index.tsx +++ b/apps/admin/src/pages/Schedules/index.tsx @@ -6,6 +6,7 @@ import { Modal, Form, Input, + InputNumber, DatePicker, TimePicker, Popconfirm, @@ -53,6 +54,7 @@ interface ClassScheduleItem { weekDay: number; startTime: string; endTime: string; + attendanceAdvanceMinutes: number; startDate: string; endDate: string; subject: string; @@ -351,7 +353,7 @@ const SchedulesPage: React.FC = () => { setEditingSchedule(null); setModalMode('create'); form.resetFields(); - form.setFieldsValue({ classroomId, weekDay }); + form.setFieldsValue({ classroomId, weekDay, attendanceAdvanceMinutes: 30 }); setModalOpen(true); } }; @@ -960,9 +962,28 @@ const SchedulesPage: React.FC = () => { /> + + + + { weekDay: selectedCell?.weekDay ?? (selectedDate ? selectedDate.day() || 7 : undefined), dateRange: selectedDate ? [selectedDate, selectedDate] : undefined, + attendanceAdvanceMinutes: 30, }); }} > @@ -1054,6 +1076,12 @@ const SchedulesPage: React.FC = () => { 时段: {s.startTime} ~ {s.endTime} + {!isMaskedSchedule(s) && ( +
+ 签到窗口: + 课前 {s.attendanceAdvanceMinutes ?? 30} 分钟至下课 +
+ )}
日期: {s.startDate} ~ {s.endDate} diff --git a/apps/admin/src/pages/Schedules/schedule-form.integration.test.ts b/apps/admin/src/pages/Schedules/schedule-form.integration.test.ts index 021b143..418e492 100644 --- a/apps/admin/src/pages/Schedules/schedule-form.integration.test.ts +++ b/apps/admin/src/pages/Schedules/schedule-form.integration.test.ts @@ -16,11 +16,13 @@ describe('schedule edit form mapping', () => { startDate: '2026-07-01', endDate: '2026-07-31', notes: '需要投影设备', + attendanceAdvanceMinutes: 45, }); expect(values.classroomId).toBe(1); expect(values.weekDay).toBe(5); expect(values.notes).toBe('需要投影设备'); + expect(values.attendanceAdvanceMinutes).toBe(45); expect(values.timeRange.map((item) => item.format('HH:mm'))).toEqual(['14:00', '18:00']); expect(values.dateRange.map((item) => item.format('YYYY-MM-DD'))).toEqual([ '2026-07-01', @@ -39,6 +41,7 @@ describe('schedule edit form mapping', () => { timeRange: [dayjs('2026-01-01 13:30'), dayjs('2026-01-01 17:20')], dateRange: [dayjs('2026-08-01'), dayjs('2026-08-31')], notes: ' 临时调整教室 ', + attendanceAdvanceMinutes: 20, }), ).toEqual({ classId: 1, @@ -51,6 +54,7 @@ describe('schedule edit form mapping', () => { startDate: '2026-08-01', endDate: '2026-08-31', notes: '临时调整教室', + attendanceAdvanceMinutes: 20, }); }); }); @@ -67,6 +71,7 @@ describe('schedule notes normalization', () => { timeRange: [dayjs('2026-01-01 13:30'), dayjs('2026-01-01 17:20')], dateRange: [dayjs('2026-08-01'), dayjs('2026-08-31')], notes: ' ', + attendanceAdvanceMinutes: 30, }).notes, ).toBeUndefined(); }); diff --git a/apps/admin/src/pages/Schedules/schedule-form.ts b/apps/admin/src/pages/Schedules/schedule-form.ts index dc4560c..2139692 100644 --- a/apps/admin/src/pages/Schedules/schedule-form.ts +++ b/apps/admin/src/pages/Schedules/schedule-form.ts @@ -7,6 +7,7 @@ export interface ScheduleFormValues { subject: string; teacherId?: number; notes?: string; + attendanceAdvanceMinutes: number; timeRange: [Dayjs, Dayjs]; dateRange: [Dayjs, Dayjs]; } @@ -19,6 +20,7 @@ export interface EditableSchedule { subject: string; teacherId: number | null; notes?: string | null; + attendanceAdvanceMinutes?: number | null; startTime: string; endTime: string; startDate: string; @@ -32,6 +34,7 @@ export const scheduleToFormValues = (schedule: EditableSchedule): ScheduleFormVa subject: schedule.subject, teacherId: schedule.teacherId ?? undefined, notes: schedule.notes ?? undefined, + attendanceAdvanceMinutes: schedule.attendanceAdvanceMinutes ?? 30, timeRange: [dayjs(`2000-01-01 ${schedule.startTime}`), dayjs(`2000-01-01 ${schedule.endTime}`)], dateRange: [dayjs(schedule.startDate), dayjs(schedule.endDate)], }); @@ -43,6 +46,7 @@ export const buildSchedulePayload = (values: ScheduleFormValues) => ({ subject: values.subject, teacherId: values.teacherId, notes: values.notes?.trim() || undefined, + attendanceAdvanceMinutes: values.attendanceAdvanceMinutes, startTime: values.timeRange[0].format('HH:mm'), endTime: values.timeRange[1].format('HH:mm'), startDate: values.dateRange[0].format('YYYY-MM-DD'), diff --git a/apps/server/src/archive/archive.controller.ts b/apps/server/src/archive/archive.controller.ts index 3c626af..27bef77 100644 --- a/apps/server/src/archive/archive.controller.ts +++ b/apps/server/src/archive/archive.controller.ts @@ -11,6 +11,7 @@ import { UseInterceptors, UploadedFile, Res, + ParseIntPipe, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import type { Request as ExpressRequest, Response } from 'express'; @@ -47,15 +48,15 @@ export class ArchiveController { @Get(':studentId') @RequirePermission('student:view') - async getProfile(@Param('studentId') studentId: string, @Request() req: AuthenticatedRequest) { + async getProfile(@Param('studentId', ParseIntPipe) studentId: number, @Request() req: AuthenticatedRequest) { const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.getProfile(+studentId); + const result = await this.archiveService.getProfile(studentId); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '学生档案', action: '查看档案', - targetId: +studentId, + targetId: studentId, targetType: 'archive', ipAddress, userAgent, @@ -66,18 +67,18 @@ export class ArchiveController { @Put(':studentId/profile') @RequirePermission('student:edit') async upsertProfile( - @Param('studentId') studentId: string, + @Param('studentId', ParseIntPipe) studentId: number, @Body() dto: UpsertProfileDto, @Request() req: AuthenticatedRequest, ) { const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.upsertProfile(+studentId, dto); + const result = await this.archiveService.upsertProfile(studentId, dto); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '学生档案', action: '更新档案信息', - targetId: +studentId, + targetId: studentId, targetType: 'student_profile', detail: JSON.stringify(dto), ipAddress, @@ -89,12 +90,12 @@ export class ArchiveController { @Post(':studentId/enrollments') @RequirePermission('student:edit') async addEnrollment( - @Param('studentId') studentId: string, + @Param('studentId', ParseIntPipe) studentId: number, @Body() dto: CreateEnrollmentDto, @Request() req: AuthenticatedRequest, ) { const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.addEnrollment(+studentId, dto); + const result = await this.archiveService.addEnrollment(studentId, dto); await this.logService.log({ userId: req.user?.id, username: req.user?.username, @@ -112,18 +113,18 @@ export class ArchiveController { @Put('enrollments/:id') @RequirePermission('student:edit') async updateEnrollment( - @Param('id') id: string, + @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateEnrollmentDto, @Request() req: AuthenticatedRequest, ) { const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.updateEnrollment(+id, dto); + const result = await this.archiveService.updateEnrollment(id, dto); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '学生档案', action: '编辑报名记录', - targetId: +id, + targetId: id, targetType: 'student_enrollment', detail: JSON.stringify(dto), ipAddress, @@ -134,15 +135,15 @@ export class ArchiveController { @Delete('enrollments/:id') @RequirePermission('student:edit') - async deleteEnrollment(@Param('id') id: string, @Request() req: AuthenticatedRequest) { + async deleteEnrollment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.deleteEnrollment(+id); + const result = await this.archiveService.deleteEnrollment(id); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '学生档案', action: '删除报名记录', - targetId: +id, + targetId: id, targetType: 'student_enrollment', ipAddress, userAgent, @@ -153,12 +154,12 @@ export class ArchiveController { @Post(':studentId/exam-scores') @RequirePermission('student:edit') async addExamScore( - @Param('studentId') studentId: string, + @Param('studentId', ParseIntPipe) studentId: number, @Body() dto: CreateExamScoreDto, @Request() req: AuthenticatedRequest, ) { const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.addExamScore(+studentId, dto); + const result = await this.archiveService.addExamScore(studentId, dto); await this.logService.log({ userId: req.user?.id, username: req.user?.username, @@ -176,18 +177,18 @@ export class ArchiveController { @Put('exam-scores/:id') @RequirePermission('student:edit') async updateExamScore( - @Param('id') id: string, + @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateExamScoreDto, @Request() req: AuthenticatedRequest, ) { const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.updateExamScore(+id, dto); + const result = await this.archiveService.updateExamScore(id, dto); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '学生档案', action: '编辑考试成绩', - targetId: +id, + targetId: id, targetType: 'exam_score', detail: JSON.stringify(dto), ipAddress, @@ -198,15 +199,15 @@ export class ArchiveController { @Delete('exam-scores/:id') @RequirePermission('student:edit') - async deleteExamScore(@Param('id') id: string, @Request() req: AuthenticatedRequest) { + async deleteExamScore(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.deleteExamScore(+id); + const result = await this.archiveService.deleteExamScore(id); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '学生档案', action: '删除考试成绩', - targetId: +id, + targetId: id, targetType: 'exam_score', ipAddress, userAgent, @@ -217,12 +218,12 @@ export class ArchiveController { @Post(':studentId/learning-records') @RequirePermission('student:edit') async addLearningRecord( - @Param('studentId') studentId: string, + @Param('studentId', ParseIntPipe) studentId: number, @Body() dto: CreateLearningRecordDto, @Request() req: AuthenticatedRequest, ) { const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.addLearningRecord(+studentId, dto); + const result = await this.archiveService.addLearningRecord(studentId, dto); await this.logService.log({ userId: req.user?.id, username: req.user?.username, @@ -240,18 +241,18 @@ export class ArchiveController { @Put('learning-records/:id') @RequirePermission('student:edit') async updateLearningRecord( - @Param('id') id: string, + @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateLearningRecordDto, @Request() req: AuthenticatedRequest, ) { const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.updateLearningRecord(+id, dto); + const result = await this.archiveService.updateLearningRecord(id, dto); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '学生档案', action: '编辑学习记录', - targetId: +id, + targetId: id, targetType: 'learning_record', detail: JSON.stringify(dto), ipAddress, @@ -262,15 +263,15 @@ export class ArchiveController { @Delete('learning-records/:id') @RequirePermission('student:edit') - async deleteLearningRecord(@Param('id') id: string, @Request() req: AuthenticatedRequest) { + async deleteLearningRecord(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.deleteLearningRecord(+id); + const result = await this.archiveService.deleteLearningRecord(id); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '学生档案', action: '删除学习记录', - targetId: +id, + targetId: id, targetType: 'learning_record', ipAddress, userAgent, @@ -281,18 +282,18 @@ export class ArchiveController { @Put(':studentId/result') @RequirePermission('student:edit') async upsertResult( - @Param('studentId') studentId: string, + @Param('studentId', ParseIntPipe) studentId: number, @Body() dto: UpsertResultDto, @Request() req: AuthenticatedRequest, ) { const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.upsertResult(+studentId, dto); + const result = await this.archiveService.upsertResult(studentId, dto); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '学生档案', action: '更新录取结果', - targetId: +studentId, + targetId: studentId, targetType: 'result_archive', detail: JSON.stringify(dto), ipAddress, @@ -305,13 +306,13 @@ export class ArchiveController { @RequirePermission('student:edit') @UseInterceptors(FileInterceptor('file')) async uploadAttachment( - @Param('studentId') studentId: string, + @Param('studentId', ParseIntPipe) studentId: number, @UploadedFile() file: Express.Multer.File, @Body('category') category: string, @Request() req: AuthenticatedRequest, ) { const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.addAttachment(+studentId, file, category || 'other'); + const result = await this.archiveService.addAttachment(studentId, file, category || 'other'); await this.logService.log({ userId: req.user?.id, username: req.user?.username, @@ -329,13 +330,13 @@ export class ArchiveController { @Get(':studentId/attachments/:id') @RequirePermission('student:view') async downloadAttachment( - @Param('studentId') studentId: string, - @Param('id') id: string, + @Param('studentId', ParseIntPipe) studentId: number, + @Param('id', ParseIntPipe) id: number, @Res() res: Response, ) { const { fullPath, fileName, mimeType } = await this.archiveService.getAttachmentFile( - +studentId, - +id, + studentId, + id, ); res.setHeader('Content-Type', mimeType); res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(fileName)}"`); @@ -345,15 +346,15 @@ export class ArchiveController { @Delete('attachments/:id') @RequirePermission('student:edit') - async deleteAttachment(@Param('id') id: string, @Request() req: AuthenticatedRequest) { + async deleteAttachment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.archiveService.deleteAttachment(+id); + const result = await this.archiveService.deleteAttachment(id); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '学生档案', action: '删除附件', - targetId: +id, + targetId: id, targetType: 'archive_attachment', ipAddress, userAgent, @@ -364,7 +365,7 @@ export class ArchiveController { @Get(':studentId/report-html') @RequirePermission('student:view') async generateReportHtml( - @Param('studentId') studentId: string, + @Param('studentId', ParseIntPipe) studentId: number, @Request() req: AuthenticatedRequest, ) { const { ipAddress, userAgent } = extractRequestInfo(req); @@ -373,12 +374,12 @@ export class ArchiveController { username: req.user?.username, module: 'archive', action: 'generate_report_html', - targetId: +studentId, + targetId: studentId, targetType: 'student', ipAddress, userAgent, }); - const html = await this.reportService.generateReportHtml(+studentId); + const html = await this.reportService.generateReportHtml(studentId); return { html }; } } diff --git a/apps/server/src/archive/archive.service.spec.ts b/apps/server/src/archive/archive.service.spec.ts index 4fe9957..09c3451 100644 --- a/apps/server/src/archive/archive.service.spec.ts +++ b/apps/server/src/archive/archive.service.spec.ts @@ -17,6 +17,7 @@ describe('ArchiveService.getProfile', () => { const learningRecordRepo = { find: jest.fn().mockResolvedValue([]) }; const resultRepo = { findOne: jest.fn().mockResolvedValue(result) }; const attachmentRepo = { find: jest.fn().mockResolvedValue([]) }; + const attendanceRepo = { find: jest.fn().mockResolvedValue([]) }; const service = new ArchiveService( studentRepo as never, @@ -26,12 +27,13 @@ describe('ArchiveService.getProfile', () => { learningRecordRepo as never, resultRepo as never, attachmentRepo as never, + attendanceRepo as never, {} as never, ); const response = await service.getProfile(7); - expect(response).toMatchObject({ student, result }); + expect(response).toMatchObject({ student, result, attendances: [] }); expect(response).not.toHaveProperty('resultArchive'); }); }); diff --git a/apps/server/src/archive/archive.service.ts b/apps/server/src/archive/archive.service.ts index d5c17e5..ce68b02 100644 --- a/apps/server/src/archive/archive.service.ts +++ b/apps/server/src/archive/archive.service.ts @@ -12,6 +12,7 @@ import { ExamScore } from '../entities/exam-score.entity'; import { LearningRecord } from '../entities/learning-record.entity'; import { ResultArchive } from '../entities/result-archive.entity'; import { ArchiveAttachment } from '../entities/archive-attachment.entity'; +import { AttendanceRecord } from '../entities/attendance-record.entity'; import { UpsertProfileDto, CreateEnrollmentDto, @@ -33,6 +34,7 @@ export class ArchiveService { @InjectRepository(LearningRecord) private learningRecordRepo: Repository, @InjectRepository(ResultArchive) private resultRepo: Repository, @InjectRepository(ArchiveAttachment) private attachmentRepo: Repository, + @InjectRepository(AttendanceRecord) private attendanceRepo: Repository, private readonly notificationsService: NotificationsService, ) {} @@ -59,7 +61,7 @@ export class ArchiveService { const student = await this.studentRepo.findOne({ where: { id: studentId } }); if (!student) throw new NotFoundException('学生不存在'); - const [profileRaw, enrollments, examScores, learningRecords, resultArchive, attachments] = + const [profileRaw, enrollments, examScores, learningRecords, resultArchive, attachments, attendances] = await Promise.all([ this.profileRepo.findOne({ where: { studentId } }), this.enrollmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }), @@ -67,6 +69,11 @@ export class ArchiveService { this.learningRecordRepo.find({ where: { studentId }, order: { recordDate: 'DESC' } }), this.resultRepo.findOne({ where: { studentId } }), this.attachmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }), + this.attendanceRepo.find({ + where: { studentId }, + relations: ['schedule', 'class'], + order: { attendanceDate: 'DESC', punchTime: 'DESC' }, + }), ]); return { @@ -77,6 +84,7 @@ export class ArchiveService { learningRecords, result: resultArchive, attachments, + attendances, }; } diff --git a/apps/server/src/attendance/attendance-settlement.service.spec.ts b/apps/server/src/attendance/attendance-settlement.service.spec.ts index 13de319..6c1bbce 100644 --- a/apps/server/src/attendance/attendance-settlement.service.spec.ts +++ b/apps/server/src/attendance/attendance-settlement.service.spec.ts @@ -20,6 +20,14 @@ const createService = () => { update: jest.fn().mockResolvedValue({ affected: 1 }), }; const attendanceService = { + getLessonAttendanceImportDateRange: jest.fn().mockImplementation((targetSchedule, lessonDate: string) => { + if (targetSchedule.endTime > targetSchedule.startTime) { + return { startDate: lessonDate, endDate: lessonDate }; + } + const next = new Date(`${lessonDate}T00:00:00.000Z`); + next.setUTCDate(next.getUTCDate() + 1); + return { startDate: lessonDate, endDate: next.toISOString().slice(0, 10) }; + }), getTeacherClassDingUserIds: jest.fn().mockResolvedValue(['ding-1']), createLessonAttendanceFromDingTalk: jest.fn().mockImplementation( async (_scheduleId: number, lessonDate: string, userId: number, finalize: boolean) => ({ diff --git a/apps/server/src/attendance/attendance-settlement.service.ts b/apps/server/src/attendance/attendance-settlement.service.ts index 0d887b2..fda60e1 100644 --- a/apps/server/src/attendance/attendance-settlement.service.ts +++ b/apps/server/src/attendance/attendance-settlement.service.ts @@ -110,9 +110,12 @@ export class AttendanceSettlementService { schedule.teacherId, schedule.classId, ); + const importRange = this.attendanceService.getLessonAttendanceImportDateRange( + schedule, + lessonDate, + ); const imported = await this.importService.importFromDingTalk({ - startDate: lessonDate, - endDate: this.isOvernight(schedule) ? this.shiftDate(lessonDate, 1) : lessonDate, + ...importRange, userIds, autoMatch: true, userId: schedule.teacherId, diff --git a/apps/server/src/attendance/attendance.controller.spec.ts b/apps/server/src/attendance/attendance.controller.spec.ts index d0e14ed..83fdb1e 100644 --- a/apps/server/src/attendance/attendance.controller.spec.ts +++ b/apps/server/src/attendance/attendance.controller.spec.ts @@ -162,6 +162,9 @@ describe('AttendanceController — write data scope', () => { assertClassAccess: jest.fn(), getAccessibleClassIds: jest.fn(), getTeacherClassDingUserIds: jest.fn(), + getLessonAttendanceImportDateRange: jest.fn().mockImplementation( + (_schedule, lessonDate: string) => ({ startDate: lessonDate, endDate: lessonDate }), + ), batchCreate: jest.fn(), generateFromSchedules: jest.fn(), findAttendanceRecord: jest.fn(), diff --git a/apps/server/src/attendance/attendance.controller.ts b/apps/server/src/attendance/attendance.controller.ts index 9f4d07c..71aea13 100644 --- a/apps/server/src/attendance/attendance.controller.ts +++ b/apps/server/src/attendance/attendance.controller.ts @@ -13,6 +13,7 @@ import { Res, BadRequestException, ForbiddenException, + ParseIntPipe, } from '@nestjs/common'; import { Observable, filter } from 'rxjs'; import type { Request as ExpressRequest, Response } from 'express'; @@ -27,6 +28,7 @@ import { QueryDingRawDto, MatchDingRecordDto, AttendanceReportQueryDto, + AttendanceAlertsQueryDto, UpdateAttendanceRecordDto, GenerateFromSchedulesDto, LessonAttendanceQueryDto, @@ -93,11 +95,11 @@ export class AttendanceController { @Get('attendance-lessons/schedules/:scheduleId') @RequirePermission('attendance:view') async getLessonAttendance( - @Param('scheduleId') scheduleId: string, + @Param('scheduleId', ParseIntPipe) scheduleId: number, @Query() query: LessonAttendanceQueryDto, @Request() req: { user: RequestUser }, ) { - const result = await this.service.getLessonAttendance(+scheduleId, query.date); + const result = await this.service.getLessonAttendance(scheduleId, query.date); await this.assertClassAccess(req, result.schedule.classId!); return result; } @@ -105,26 +107,29 @@ export class AttendanceController { @Post('attendance-lessons/schedules/:scheduleId/pull') @RequirePermission('attendance:create') async pullLessonAttendance( - @Param('scheduleId') scheduleId: string, + @Param('scheduleId', ParseIntPipe) scheduleId: number, @Body() dto: StartLessonAttendanceDto, @Request() req: { user: RequestUser }, ) { - const schedule = await this.service.getLessonAttendance(+scheduleId, dto.date); + const schedule = await this.service.getLessonAttendance(scheduleId, dto.date); await this.assertClassAccess(req, schedule.schedule.classId!); const importClassIds = await this.service.getTeacherClassDingUserIds( req.user.id, schedule.schedule.classId!, this.canManageAllAttendance(req), ); + const importRange = this.service.getLessonAttendanceImportDateRange( + schedule.schedule, + dto.date, + ); const importResult = await this.importService.importFromDingTalk({ - startDate: dto.date, - endDate: dto.date, + ...importRange, userIds: importClassIds, autoMatch: true, userId: req.user.id, }); const result = await this.service.createLessonAttendanceFromDingTalk( - +scheduleId, + scheduleId, dto.date, req.user.id, ); @@ -143,18 +148,18 @@ export class AttendanceController { @Post('attendance-lessons/:sessionId/complete') @RequirePermission('attendance:create') async completeLessonAttendance( - @Param('sessionId') sessionId: string, + @Param('sessionId', ParseIntPipe) sessionId: number, @Request() req: { user: RequestUser }, ) { - const session = await this.service.findAttendanceSession(+sessionId); + const session = await this.service.findAttendanceSession(sessionId); await this.assertClassAccess(req, session.classId); - const result = await this.service.completeLessonAttendance(+sessionId, req.user.id); + const result = await this.service.completeLessonAttendance(sessionId, req.user.id); await this.logService.log({ userId: req.user.id, username: req.user.username, module: '考勤管理', action: '完成课程点名', - targetId: +sessionId, + targetId: sessionId, targetType: 'attendanceSession', detail: `班级${session.classId} 日期${session.lessonDate}`, }); @@ -278,23 +283,23 @@ export class AttendanceController { @Put('attendance-records/:id') @RequirePermission('attendance:edit', 'attendance:self-edit') async update( - @Param('id') id: string, + @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateAttendanceRecordDto, @Request() req: any, ) { const { ipAddress, userAgent } = extractRequestInfo(req); - const existing = await this.service.findAttendanceRecord(+id); + const existing = await this.service.findAttendanceRecord(id); if (existing.classId == null && !this.canManageAllAttendance(req)) { throw new ForbiddenException('无权修改未关联班级的考勤记录'); } if (existing.classId != null) await this.assertClassAccess(req, existing.classId); - const result = await this.service.update(+id, dto); + const result = await this.service.update(id, dto); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '考勤管理', action: '编辑考勤记录', - targetId: +id, + targetId: id, targetType: 'attendanceRecord', detail: `状态=${result.status}, 备注=${result.remark || ''}`, ipAddress, @@ -306,20 +311,20 @@ export class AttendanceController { // ── Delete a single attendance record ── @Delete('attendance-records/:id') @RequirePermission('attendance:edit', 'attendance:self-edit') - async remove(@Param('id') id: string, @Request() req: any) { + async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); - const existing = await this.service.findAttendanceRecord(+id); + const existing = await this.service.findAttendanceRecord(id); if (existing.classId == null && !this.canManageAllAttendance(req)) { throw new ForbiddenException('无权删除未关联班级的考勤记录'); } if (existing.classId != null) await this.assertClassAccess(req, existing.classId); - const result = await this.service.remove(+id); + const result = await this.service.remove(id); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '考勤管理', action: '删除考勤记录', - targetId: +id, + targetId: id, targetType: 'attendanceRecord', detail: `删除考勤记录 ${id}`, ipAddress, @@ -369,18 +374,18 @@ export class AttendanceController { @Post('ding-attendance-raw/:id/match') @RequirePermission('attendance:edit') async matchDingRecord( - @Param('id') id: string, + @Param('id', ParseIntPipe) id: number, @Body() dto: MatchDingRecordDto, @Request() req: any, ) { const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.service.matchDingRecord(+id, dto); + const result = await this.service.matchDingRecord(id, dto); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '考勤管理', action: '匹配考勤记录', - targetId: +id, + targetId: id, targetType: 'dingAttendanceRaw', detail: `匹配到学生 ${dto.studentId}`, ipAddress, @@ -458,12 +463,11 @@ export class AttendanceController { @RequirePermission('attendance:view') async getAlerts( @Request() req: { user: RequestUser }, - @Query('days') days?: string, - @Query('threshold') threshold?: string, + @Query() query: AttendanceAlertsQueryDto, ) { return this.service.getAlerts( - days ? +days : 14, - threshold ? +threshold : 3, + query.days ?? 14, + query.threshold ?? 3, await this.getAccessibleClassIds(req), ); } diff --git a/apps/server/src/attendance/attendance.lesson-session.spec.ts b/apps/server/src/attendance/attendance.lesson-session.spec.ts index 140ccda..ffeb36a 100644 --- a/apps/server/src/attendance/attendance.lesson-session.spec.ts +++ b/apps/server/src/attendance/attendance.lesson-session.spec.ts @@ -59,6 +59,7 @@ const endedSchedule = { subject: '\u6570\u5B66', status: 'active', scheduleType: 'INTERNAL', + attendanceAdvanceMinutes: 30, }; describe('AttendanceService \u2014 DingTalk course attendance', () => { @@ -186,6 +187,40 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => { ]); }); + it('counts both OnDuty and OffDuty punches only inside the configured window', async () => { + const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } = + createService(); + scheduleRepo.findOne.mockResolvedValue({ ...endedSchedule, attendanceAdvanceMinutes: 20 }); + sessionRepo.findOne.mockResolvedValue(null); + classStudentRepo.find.mockResolvedValue([ + { studentId: 1, student: { id: 1, name: '张三' } }, + { studentId: 2, student: { id: 2, name: '李四' } }, + { studentId: 3, student: { id: 3, name: '王五' } }, + ]); + dingRawRepo.find.mockResolvedValue([ + { matchedStudentId: 1, attendanceType: 'OffDuty', checkOutTime: new Date('2026-07-11T08:40:00+08:00') }, + { matchedStudentId: 2, attendanceType: 'OffDuty', checkOutTime: new Date('2026-07-11T10:00:00+08:00') }, + { matchedStudentId: 3, attendanceType: 'OnDuty', checkInTime: new Date('2026-07-11T08:39:59+08:00') }, + { matchedStudentId: 3, attendanceType: 'OffDuty', checkOutTime: new Date('2026-07-11T10:00:01+08:00') }, + ]); + + await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21, true); + + expect(attendanceRepo.save).toHaveBeenCalledWith([ + expect.objectContaining({ studentId: 1, status: 'present' }), + expect.objectContaining({ studentId: 2, status: 'present' }), + expect.objectContaining({ studentId: 3, status: 'absent' }), + ]); + }); + + it('expands import dates when the pre-class window crosses midnight', () => { + const { service } = createService(); + expect(service.getLessonAttendanceImportDateRange( + { startTime: '00:15', endTime: '01:00', attendanceAdvanceMinutes: 30 }, + '2026-07-11', + )).toEqual({ startDate: '2026-07-10', endDate: '2026-07-11' }); + }); + it('creates local attendance after the lesson starts', async () => { const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } = createService(); diff --git a/apps/server/src/attendance/attendance.service.ts b/apps/server/src/attendance/attendance.service.ts index ae833f3..f4ba5fd 100644 --- a/apps/server/src/attendance/attendance.service.ts +++ b/apps/server/src/attendance/attendance.service.ts @@ -175,24 +175,45 @@ export class AttendanceService { return { schedule, session, records }; } + private getLessonAttendanceWindow( + schedule: Pick, + lessonDate: string, + ): { start: number; end: number; dateFrom: string; dateTo: string } { + const startMinuteOfDay = this.toMinutes(schedule.startTime); + const endMinuteOfDay = this.toMinutes(schedule.endTime); + const advanceMinutes = Math.max(0, schedule.attendanceAdvanceMinutes ?? 30); + const lessonStart = new Date(`${lessonDate}T${schedule.startTime}:00+08:00`).getTime(); + let lessonEnd = new Date(`${lessonDate}T${schedule.endTime}:00+08:00`).getTime(); + const overnight = endMinuteOfDay <= startMinuteOfDay; + if (overnight) lessonEnd += 24 * 60 * 60 * 1000; + + return { + start: lessonStart - advanceMinutes * 60 * 1000, + end: lessonEnd, + dateFrom: advanceMinutes > startMinuteOfDay ? this.shiftDate(lessonDate, -1) : lessonDate, + dateTo: overnight ? this.shiftDate(lessonDate, 1) : lessonDate, + }; + } + + getLessonAttendanceImportDateRange( + schedule: Pick, + lessonDate: string, + ): { startDate: string; endDate: string } { + const window = this.getLessonAttendanceWindow(schedule, lessonDate); + return { startDate: window.dateFrom, endDate: window.dateTo }; + } + private selectDingTalkRecordsForLesson( records: DingAttendanceRaw[], + schedule: Pick, lessonDate: string, - startTime: string, - endTime: string, ): DingAttendanceRaw[] { - const [startHour, startMinute] = startTime.split(':').map(Number); - const [endHour, endMinute] = endTime.split(':').map(Number); - const start = new Date(`${lessonDate}T${startTime}:00+08:00`).getTime(); - let end = new Date(`${lessonDate}T${endTime}:00+08:00`).getTime(); - if (endHour * 60 + endMinute <= startHour * 60 + startMinute) end += 24 * 60 * 60 * 1000; - const windowStart = start - 3 * 60 * 60 * 1000; - const windowEnd = end + 3 * 60 * 60 * 1000; - const timed = records.filter((record) => { + const window = this.getLessonAttendanceWindow(schedule, lessonDate); + return records.filter((record) => { + // 上班、下班打卡都有效,按原始记录中实际存在的时间判断。 const time = record.checkInTime ?? record.checkOutTime; - return time && time.getTime() >= windowStart && time.getTime() <= windowEnd; + return time && time.getTime() >= window.start && time.getTime() <= window.end; }); - return timed.length > 0 ? timed : records.filter((record) => !record.checkInTime && !record.checkOutTime); } private mapDingTalkStatus(records: DingAttendanceRaw[], finalize = false): string { @@ -291,7 +312,7 @@ export class AttendanceService { return this.dataSource.transaction(async (manager) => { const sessionRepo = manager.getRepository(AttendanceSession); const recordRepo = manager.getRepository(AttendanceRecord); - const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, lessonDate); + const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, schedule, lessonDate); const existingRecords = await recordRepo.find({ where: { attendanceSessionId: existing.id }, order: { studentId: 'ASC' }, @@ -312,9 +333,8 @@ export class AttendanceService { const raw = this.selectDingTalkRecordsForLesson( rawByStudent.get(record.studentId) ?? [], + schedule, lessonDate, - schedule.startTime, - schedule.endTime, ); record.status = this.mapDingTalkStatus(raw, finalize); Object.assign(record, this.getLessonPunchMetadata( @@ -333,9 +353,8 @@ export class AttendanceService { if (existingStudentIds.has(classStudent.studentId)) continue; const raw = this.selectDingTalkRecordsForLesson( rawByStudent.get(classStudent.studentId) ?? [], + schedule, lessonDate, - schedule.startTime, - schedule.endTime, ); updatedRecords.push( recordRepo.create({ @@ -377,7 +396,7 @@ export class AttendanceService { return this.dataSource.transaction(async (manager) => { const sessionRepo = manager.getRepository(AttendanceSession); const recordRepo = manager.getRepository(AttendanceRecord); - const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, lessonDate); + const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, schedule, lessonDate); const classStudents = await this.classStudentRepo.find({ where: { classId: schedule.classId!, status: 'active' }, @@ -421,9 +440,8 @@ export class AttendanceService { const records = classStudents.map((classStudent) => { const raw = this.selectDingTalkRecordsForLesson( rawByStudent.get(classStudent.studentId) ?? [], + schedule, lessonDate, - schedule.startTime, - schedule.endTime, ); return recordRepo.create({ studentId: classStudent.studentId, @@ -460,6 +478,7 @@ export class AttendanceService { private async fetchDingTalkRawByStudent( classId: number, + schedule: Pick, lessonDate: string, ): Promise> { const classStudents = await this.classStudentRepo.find({ @@ -467,9 +486,10 @@ export class AttendanceService { }); if (classStudents.length === 0) return new Map(); const studentIds = classStudents.map((cs) => cs.studentId); + const window = this.getLessonAttendanceWindow(schedule, lessonDate); const rawRecords = await this.dingRawRepo.find({ where: { - attendanceDate: lessonDate, + attendanceDate: Between(window.dateFrom, window.dateTo), matchedStudentId: In(studentIds), }, }); @@ -651,6 +671,17 @@ export class AttendanceService { }); } + private toMinutes(time: string): number { + const [hour, minute] = time.split(':').map(Number); + return hour * 60 + minute; + } + + private shiftDate(date: string, days: number): string { + const shifted = new Date(`${date}T00:00:00.000Z`); + shifted.setUTCDate(shifted.getUTCDate() + days); + return shifted.toISOString().slice(0, 10); + } + private mapScheduleTimeToSession(startTime: string): string { const hour = parseInt(startTime.slice(0, 2), 10); if (hour < 8) return 'morning_reading'; diff --git a/apps/server/src/attendance/dto/attendance.dto.ts b/apps/server/src/attendance/dto/attendance.dto.ts index 201855e..22a01c8 100644 --- a/apps/server/src/attendance/dto/attendance.dto.ts +++ b/apps/server/src/attendance/dto/attendance.dto.ts @@ -7,6 +7,9 @@ import { IsIn, ValidateNested, IsNotEmpty, + ArrayNotEmpty, + Max, + Min, } from 'class-validator'; import { Type } from 'class-transformer'; @@ -44,6 +47,7 @@ export class AttendanceRecordItem { export class BatchCreateAttendanceDto { @IsArray() + @ArrayNotEmpty() @ValidateNested({ each: true }) @Type(() => AttendanceRecordItem) records: AttendanceRecordItem[]; @@ -96,11 +100,14 @@ export class QueryDingRawDto { @IsOptional() @IsInt() + @Min(1) @Type(() => Number) page?: number; @IsOptional() @IsInt() + @Min(1) + @Max(200) @Type(() => Number) pageSize?: number; } @@ -139,11 +146,14 @@ export class QueryAttendanceRecordsDto { @IsOptional() @IsInt() + @Min(1) @Type(() => Number) page?: number; @IsOptional() @IsInt() + @Min(1) + @Max(200) @Type(() => Number) pageSize?: number; } @@ -165,6 +175,22 @@ export class UpdateAttendanceRecordDto { remark?: string; } + +export class AttendanceAlertsQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(365) + days?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + threshold?: number; +} + export class AttendanceReportQueryDto { @IsOptional() @IsInt() diff --git a/apps/server/src/bills/bills.controller.ts b/apps/server/src/bills/bills.controller.ts index 82643e7..62b08f4 100644 --- a/apps/server/src/bills/bills.controller.ts +++ b/apps/server/src/bills/bills.controller.ts @@ -11,6 +11,7 @@ import { Request, Res, Req, + ParseIntPipe, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In } from 'typeorm'; @@ -75,38 +76,38 @@ export class BillsController { findAll( @Query('periodStart') periodStart?: string, @Query('periodEnd') periodEnd?: string, - @Query('studentId') studentId?: string, + @Query('studentId', new ParseIntPipe({ optional: true })) studentId?: number, @Query('status') status?: string, @Query('expenseType') expenseType?: string, ) { return this.service.findAll({ periodStart, periodEnd, - studentId: studentId ? +studentId : undefined, + studentId, status, expenseType, }); } @Get(':id') @RequirePermission('bill:view') - findOne(@Param('id') id: string) { - return this.service.findOne(+id); + findOne(@Param('id', ParseIntPipe) id: number) { + return this.service.findOne(id); } @Put(':id/status') @RequirePermission('bill:confirm') async updateStatus( - @Param('id') id: string, + @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateBillStatusDto, @Request() req: any, ) { const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.service.updateStatus(+id, dto); + const result = await this.service.updateStatus(id, dto); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '账单管理', action: '确认账单', - targetId: +id, + targetId: id, targetType: 'bill', ipAddress, userAgent, @@ -160,15 +161,15 @@ export class BillsController { @Post(':id/cancel') @RequirePermission('bill:delete') - async cancel(@Param('id') id: string, @Body() dto: CancelBillDto, @Request() req: any) { - const result = await this.service.cancel(+id, dto, req.user?.id); + async cancel(@Param('id', ParseIntPipe) id: number, @Body() dto: CancelBillDto, @Request() req: any) { + const result = await this.service.cancel(id, dto, req.user?.id); const { ipAddress, userAgent } = extractRequestInfo(req); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '账单管理', action: '取消账单并冲正', - targetId: +id, + targetId: id, targetType: 'bill', detail: dto.reason, ipAddress, @@ -179,15 +180,15 @@ export class BillsController { @Delete(':id') @RequirePermission('bill:delete') - async remove(@Param('id') id: string, @Request() req: any) { + async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.service.remove(+id); + const result = await this.service.remove(id); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '账单管理', action: '删除账单', - targetId: +id, + targetId: id, targetType: 'bill', ipAddress, userAgent, @@ -217,7 +218,7 @@ export class BillsController { async exportExcel( @Query('periodStart') periodStart?: string, @Query('periodEnd') periodEnd?: string, - @Query('studentId') studentId?: string, + @Query('studentId', new ParseIntPipe({ optional: true })) studentId?: number, @Query('status') status?: string, @Res() res?: Response, @Req() req?: any, @@ -236,7 +237,7 @@ export class BillsController { { periodStart, periodEnd, - studentId: studentId ? +studentId : undefined, + studentId, status, }, res!, @@ -245,18 +246,18 @@ export class BillsController { @Get('export/pdf/:id') @RequirePermission('bill:export-pdf') - async exportPdf(@Param('id') id: string, @Res() res: Response, @Req() req: any) { + async exportPdf(@Param('id', ParseIntPipe) id: number, @Res() res: Response, @Req() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); await this.logService.log({ userId: req?.user?.id, username: req?.user?.username, module: '账单管理', action: '导出账单', - targetId: +id, + targetId: id, targetType: 'bill', ipAddress, userAgent, }); - return this.exportService.exportStudentPdf(+id, res); + return this.exportService.exportStudentPdf(id, res); } } diff --git a/apps/server/src/bills/bills.module.ts b/apps/server/src/bills/bills.module.ts index 27cf60c..0631108 100644 --- a/apps/server/src/bills/bills.module.ts +++ b/apps/server/src/bills/bills.module.ts @@ -9,6 +9,7 @@ import { PersonalExpense } from '../entities/personal-expense.entity'; import { Occupancy } from '../entities/occupancy.entity'; import { Room } from '../entities/room.entity'; import { Student } from '../entities/student.entity'; +import { Deposit } from '../entities/deposit.entity'; import { BillsService } from './bills.service'; import { BillsExportService } from './bills-export.service'; import { BillsController } from './bills.controller'; @@ -23,6 +24,7 @@ import { BillsController } from './bills.controller'; Occupancy, Room, Student, + Deposit, ]), NotificationsModule, WalletsModule, diff --git a/apps/server/src/database/database-migrations.service.ts b/apps/server/src/database/database-migrations.service.ts index d39d855..372e45f 100644 --- a/apps/server/src/database/database-migrations.service.ts +++ b/apps/server/src/database/database-migrations.service.ts @@ -260,7 +260,11 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap { const runner = this.dataSource.createQueryRunner(); await runner.connect(); try { - const tables = await runner.getTables(['attendance_records', 'attendance_sessions']); + const tables = await runner.getTables([ + 'class_schedule', + 'attendance_records', + 'attendance_sessions', + ]); const tableNames = new Set(tables.map((table) => table.name)); const isMySQL = this.dataSource.options.type === 'mysql'; @@ -287,6 +291,17 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap { `); } + if (tableNames.has('class_schedule')) { + const scheduleTable = await runner.getTable('class_schedule'); + const scheduleColumns = new Set(scheduleTable?.columns.map((column) => column.name) ?? []); + if (!scheduleColumns.has('attendance_advance_minutes')) { + await runner.query( + 'ALTER TABLE class_schedule ADD COLUMN attendance_advance_minutes INTEGER NOT NULL DEFAULT 30', + ); + this.logger.log('已为排课添加课前签到分钟配置'); + } + } + const attendanceTable = await runner.getTable('attendance_records'); const columnNames = new Set(attendanceTable?.columns.map((column) => column.name) ?? []); if (!columnNames.has('schedule_id')) { diff --git a/apps/server/src/database/database-migrations.spec.ts b/apps/server/src/database/database-migrations.spec.ts index 6000976..84d75e1 100644 --- a/apps/server/src/database/database-migrations.spec.ts +++ b/apps/server/src/database/database-migrations.spec.ts @@ -209,6 +209,28 @@ describe('DatabaseMigrationsService — course attendance schema', () => { expect(runner.release).toHaveBeenCalled(); }); + it('adds the configurable attendance window to existing schedules', async () => { + const runner = mockRunner({ + getTables: [ + { name: 'class_schedule', columns: [{ name: 'id' }] }, + { name: 'attendance_records', columns: [{ name: 'id' }] }, + { name: 'attendance_sessions', columns: [{ name: 'id' }] }, + ], + }); + runner.getTable.mockImplementation(async (name: string) => + name === 'class_schedule' + ? { name, columns: [{ name: 'id' }] } + : { name, columns: [{ name: 'id' }, { name: 'schedule_id' }, { name: 'attendance_session_id' }] }, + ); + await bootstrapCourseAttendance(runner); + + await service.ensureCourseAttendanceSchema(); + + expect(runner.query).toHaveBeenCalledWith( + expect.stringContaining('ALTER TABLE class_schedule ADD COLUMN attendance_advance_minutes'), + ); + }); + it('creates attendance_sessions with FK RESTRICT constraints when table is missing', async () => { const runner = mockRunner({ getTables: [{ name: 'attendance_records', columns: [{ name: 'id' }] }], diff --git a/apps/server/src/deposits/deposits.controller.ts b/apps/server/src/deposits/deposits.controller.ts index bc347dd..464932b 100644 --- a/apps/server/src/deposits/deposits.controller.ts +++ b/apps/server/src/deposits/deposits.controller.ts @@ -9,6 +9,7 @@ import { Query, UseGuards, Request, + ParseIntPipe, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; @@ -16,7 +17,12 @@ import { Student } from '../entities/student.entity'; import { DepositsService } from './deposits.service'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationType } from '../entities/notification.entity'; -import { CreateDepositDto, RefundDepositDto } from './dto/deposit.dto'; +import { + CreateDepositDto, + CreateDepositInstallmentDto, + RefundDepositDto, + UpdateDepositInstallmentDto, +} from './dto/deposit.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { extractRequestInfo } from '../common/request-utils'; @@ -40,9 +46,12 @@ export class DepositsController { @Get() @RequirePermission('deposit:view') - findAll(@Query('studentId') studentId?: string, @Query('status') status?: string) { + findAll( + @Query('studentId', new ParseIntPipe({ optional: true })) studentId?: number, + @Query('status') status?: string, + ) { return this.service.findAll({ - studentId: studentId ? +studentId : undefined, + studentId, status: status || undefined, }); } @@ -55,8 +64,8 @@ export class DepositsController { @Get(':id') @RequirePermission('deposit:view') - findOne(@Param('id') id: string) { - return this.service.findOne(+id); + findOne(@Param('id', ParseIntPipe) id: number) { + return this.service.findOne(id); } @Post() @@ -93,12 +102,12 @@ export class DepositsController { @Post(':id/installments') @RequirePermission('deposit:edit') async addInstallment( - @Param('id') id: string, - @Body() body: { amount: number; dueDate: string }, + @Param('id', ParseIntPipe) id: number, + @Body() body: CreateDepositInstallmentDto, @Request() req: { user?: { id: number; username: string }; headers?: Record }, ) { const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.service.addInstallment(+id, body.amount, body.dueDate); + const result = await this.service.addInstallment(id, body.amount, body.dueDate); await this.logService.log({ userId: req.user?.id, username: req.user?.username, @@ -116,18 +125,18 @@ export class DepositsController { @Put('installments/:installmentId') @RequirePermission('deposit:edit') async updateInstallment( - @Param('installmentId') installmentId: string, - @Body() body: { paidDate?: string; status?: string }, + @Param('installmentId', ParseIntPipe) installmentId: number, + @Body() body: UpdateDepositInstallmentDto, @Request() req: { user?: { id: number; username: string }; headers?: Record }, ) { const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.service.updateInstallment(+installmentId, body); + const result = await this.service.updateInstallment(installmentId, body); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '押金管理', action: '更新分期', - targetId: +installmentId, + targetId: installmentId, targetType: 'deposit-installment', detail: `更新分期${installmentId}, 状态:${result.status ?? '-'}, 实付日:${result.paidDate ?? '-'}`, ipAddress, @@ -139,17 +148,17 @@ export class DepositsController { @Delete('installments/:installmentId') @RequirePermission('deposit:delete') async deleteInstallment( - @Param('installmentId') installmentId: string, + @Param('installmentId', ParseIntPipe) installmentId: number, @Request() req: { user?: { id: number; username: string }; headers?: Record }, ) { const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.service.deleteInstallment(+installmentId); + const result = await this.service.deleteInstallment(installmentId); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '押金管理', action: '删除分期', - targetId: +installmentId, + targetId: installmentId, targetType: 'deposit-installment', detail: `删除分期${installmentId}`, ipAddress, @@ -160,15 +169,15 @@ export class DepositsController { @Put(':id/refund') @RequirePermission('deposit:refund') - async refund(@Param('id') id: string, @Body() dto: RefundDepositDto, @Request() req: any) { + async refund(@Param('id', ParseIntPipe) id: number, @Body() dto: RefundDepositDto, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.service.refund(+id, dto, req.user?.id); + const result = await this.service.refund(id, dto, req.user?.id); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '押金管理', action: '退还押金', - targetId: +id, + targetId: id, targetType: 'deposit', detail: `退还全部可用押金 ¥${result.refundAmount}`, ipAddress, @@ -191,15 +200,15 @@ export class DepositsController { @Delete(':id') @RequirePermission('deposit:delete') - async remove(@Param('id') id: string, @Request() req: any) { + async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.service.remove(+id); + const result = await this.service.remove(id); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '押金管理', action: '删除押金记录', - targetId: +id, + targetId: id, targetType: 'deposit', ipAddress, userAgent, diff --git a/apps/server/src/deposits/deposits.refund.spec.ts b/apps/server/src/deposits/deposits.refund.spec.ts index 8400678..9677350 100644 --- a/apps/server/src/deposits/deposits.refund.spec.ts +++ b/apps/server/src/deposits/deposits.refund.spec.ts @@ -2,7 +2,7 @@ import { DepositsService } from './deposits.service'; import { Deposit } from '../entities/deposit.entity'; describe('DepositsService — direct refund', () => { - it('stores the refund result on the main status and renamed audit fields', async () => { + it('refunds the full available balance and stores audit fields', async () => { const deposit = { id: 1, amount: 500, @@ -16,20 +16,16 @@ describe('DepositsService — direct refund', () => { const result = await service.refund( 1, - { - refundDate: '2026-07-13', - deductionAmount: 100, - deductionReason: '物品损坏', - }, + { refundDate: '2026-07-13', notes: '退还剩余押金' }, 42, ); expect(result).toMatchObject({ refundDate: '2026-07-13', - refundAmount: 400, - deductionAmount: 100, - deductionReason: '物品损坏', - status: 'partial_refund', + amount: 0, + refundAmount: 500, + notes: '退还剩余押金', + status: 'refunded', refundedBy: 42, }); expect(result.refundedAt).toBeInstanceOf(Date); diff --git a/apps/server/src/deposits/dto/deposit.dto.ts b/apps/server/src/deposits/dto/deposit.dto.ts index 80b449c..0728ab2 100644 --- a/apps/server/src/deposits/dto/deposit.dto.ts +++ b/apps/server/src/deposits/dto/deposit.dto.ts @@ -1,13 +1,14 @@ -import { IsInt, IsNumber, IsString, IsOptional } from 'class-validator'; +import { IsDateString, IsIn, IsInt, IsNumber, IsString, IsOptional, Min } from 'class-validator'; export class CreateDepositDto { @IsInt() studentId: number; - @IsNumber() + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0.01) amount: number; - @IsString() + @IsDateString() paidDate: string; @IsOptional() @@ -16,10 +17,29 @@ export class CreateDepositDto { } export class RefundDepositDto { - @IsString() + @IsDateString() refundDate: string; @IsOptional() @IsString() notes?: string; } + +export class CreateDepositInstallmentDto { + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0.01) + amount: number; + + @IsDateString() + dueDate: string; +} + +export class UpdateDepositInstallmentDto { + @IsOptional() + @IsDateString() + paidDate?: string; + + @IsOptional() + @IsIn(['pending', 'paid', 'overdue']) + status?: string; +} diff --git a/apps/server/src/entities/class-schedule.entity.ts b/apps/server/src/entities/class-schedule.entity.ts index 5ea5ddb..0e7ca4d 100644 --- a/apps/server/src/entities/class-schedule.entity.ts +++ b/apps/server/src/entities/class-schedule.entity.ts @@ -45,6 +45,10 @@ export class ClassSchedule { @Column({ name: 'end_time', length: 5 }) endTime: string; + /** 课程开始前允许计入签到的分钟数。 */ + @Column({ name: 'attendance_advance_minutes', type: 'integer', default: 30 }) + attendanceAdvanceMinutes: number; + @Column({ name: 'start_date', type: 'date' }) startDate: string; diff --git a/apps/server/src/expenses/dto/expense.dto.ts b/apps/server/src/expenses/dto/expense.dto.ts index 40e644c..bd8a0e1 100644 --- a/apps/server/src/expenses/dto/expense.dto.ts +++ b/apps/server/src/expenses/dto/expense.dto.ts @@ -1,4 +1,6 @@ -import { IsIn, IsInt, IsString, IsNumber, IsOptional, Matches, Min } from 'class-validator'; +import { IsDateString, IsIn, IsInt, IsString, IsNumber, IsOptional, Matches, Min } from 'class-validator'; +import { PartialType } from '@nestjs/mapped-types'; +import { Type } from 'class-transformer'; export class CreateRoomExpenseDto { @IsInt() @@ -7,13 +9,14 @@ export class CreateRoomExpenseDto { @IsString() expenseType: string; - @IsNumber() + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0.01) amount: number; - @IsString() + @IsDateString() periodStart: string; - @IsString() + @IsDateString() periodEnd: string; @IsOptional() @@ -32,10 +35,11 @@ export class CreatePersonalExpenseDto { @IsString() expenseType: string; - @IsNumber() + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0.01) amount: number; - @IsString() + @IsDateString() expenseDate: string; @IsOptional() @@ -43,6 +47,33 @@ export class CreatePersonalExpenseDto { description?: string; } + +export class UpdateRoomExpenseDto extends PartialType(CreateRoomExpenseDto) {} + +export class UpdatePersonalExpenseDto extends PartialType(CreatePersonalExpenseDto) {} + +export class QueryRoomExpenseDto { + @IsOptional() + @Type(() => Number) + @IsInt() + roomId?: number; + + @IsOptional() + @IsDateString() + periodStart?: string; + + @IsOptional() + @IsDateString() + periodEnd?: string; +} + +export class QueryPersonalExpenseDto { + @IsOptional() + @Type(() => Number) + @IsInt() + studentId?: number; +} + export class BatchRoomExpenseDto { @IsString() periodStart: string; diff --git a/apps/server/src/expenses/expenses.controller.ts b/apps/server/src/expenses/expenses.controller.ts index bbb042f..5c9d389 100644 --- a/apps/server/src/expenses/expenses.controller.ts +++ b/apps/server/src/expenses/expenses.controller.ts @@ -12,6 +12,7 @@ import { Res, UseInterceptors, UploadedFile, + ParseIntPipe, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import type { Response } from 'express'; @@ -21,6 +22,10 @@ import { CreatePersonalExpenseDto, BatchRoomExpenseDto, CreateStudentUtilityBillDto, + QueryPersonalExpenseDto, + QueryRoomExpenseDto, + UpdatePersonalExpenseDto, + UpdateRoomExpenseDto, } from './dto/expense.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; @@ -136,29 +141,21 @@ export class ExpensesController { @Get('room') @RequirePermission('expense:view') - findRoomExpenses( - @Query('roomId') roomId?: string, - @Query('periodStart') periodStart?: string, - @Query('periodEnd') periodEnd?: string, - ) { - return this.service.findRoomExpenses({ - roomId: roomId ? +roomId : undefined, - periodStart, - periodEnd, - }); + findRoomExpenses(@Query() query: QueryRoomExpenseDto) { + return this.service.findRoomExpenses(query); } @Delete('room/:id') @RequirePermission('expense:delete') - async deleteRoomExpense(@Param('id') id: string, @Request() req: any) { + async deleteRoomExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.service.deleteRoomExpense(+id); + const result = await this.service.deleteRoomExpense(id); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '费用管理', action: '删除费用', - targetId: +id, + targetId: id, targetType: 'room_expense', ipAddress, userAgent, @@ -186,18 +183,18 @@ export class ExpensesController { @Put('room/:id') @RequirePermission('expense:edit') async updateRoomExpense( - @Param('id') id: string, - @Body() dto: CreateRoomExpenseDto, + @Param('id', ParseIntPipe) id: number, + @Body() dto: UpdateRoomExpenseDto, @Request() req: any, ) { const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.service.updateRoomExpense(+id, dto); + const result = await this.service.updateRoomExpense(id, dto); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '费用管理', action: '编辑费用', - targetId: +id, + targetId: id, targetType: 'room_expense', detail: `¥${dto.amount} ${dto.expenseType}`, ipAddress, @@ -225,21 +222,21 @@ export class ExpensesController { @Get('personal') @RequirePermission('expense:view') - findPersonalExpenses(@Query('studentId') studentId?: string) { - return this.service.findPersonalExpenses({ studentId: studentId ? +studentId : undefined }); + findPersonalExpenses(@Query() query: QueryPersonalExpenseDto) { + return this.service.findPersonalExpenses(query); } @Delete('personal/:id') @RequirePermission('expense:delete') - async deletePersonalExpense(@Param('id') id: string, @Request() req: any) { + async deletePersonalExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.service.deletePersonalExpense(+id); + const result = await this.service.deletePersonalExpense(id); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '费用管理', action: '删除费用', - targetId: +id, + targetId: id, ipAddress, userAgent, }); @@ -266,18 +263,18 @@ export class ExpensesController { @Put('personal/:id') @RequirePermission('expense:edit') async updatePersonalExpense( - @Param('id') id: string, - @Body() dto: CreatePersonalExpenseDto, + @Param('id', ParseIntPipe) id: number, + @Body() dto: UpdatePersonalExpenseDto, @Request() req: any, ) { const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.service.updatePersonalExpense(+id, dto); + const result = await this.service.updatePersonalExpense(id, dto); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '费用管理', action: '编辑费用', - targetId: +id, + targetId: id, detail: `¥${dto.amount} ${dto.expenseType}`, ipAddress, userAgent, diff --git a/apps/server/src/schedules/dto/schedule.dto.spec.ts b/apps/server/src/schedules/dto/schedule.dto.spec.ts index 62fc926..9ca4864 100644 --- a/apps/server/src/schedules/dto/schedule.dto.spec.ts +++ b/apps/server/src/schedules/dto/schedule.dto.spec.ts @@ -15,6 +15,21 @@ const createSchedule = (notes: string) => notes, }); +describe('schedule attendance window validation', () => { + it('accepts a configurable number of minutes before class', async () => { + const dto = createSchedule(''); + dto.attendanceAdvanceMinutes = 45; + const errors = await validate(dto); + expect(errors.some((error) => error.property === 'attendanceAdvanceMinutes')).toBe(false); + }); + + it('rejects values outside 0 to 1440 minutes', async () => { + const dto = Object.assign(new UpdateScheduleDto(), { attendanceAdvanceMinutes: 1441 }); + const errors = await validate(dto); + expect(errors.some((error) => error.property === 'attendanceAdvanceMinutes')).toBe(true); + }); +}); + describe('schedule notes validation', () => { it('rejects notes longer than 500 characters when creating', async () => { const errors = await validate(createSchedule('a'.repeat(501))); diff --git a/apps/server/src/schedules/dto/schedule.dto.ts b/apps/server/src/schedules/dto/schedule.dto.ts index b193c59..b2351a7 100644 --- a/apps/server/src/schedules/dto/schedule.dto.ts +++ b/apps/server/src/schedules/dto/schedule.dto.ts @@ -34,6 +34,12 @@ export class CreateScheduleDto { @IsNotEmpty() endTime: string; + @IsOptional() + @IsInt() + @Min(0) + @Max(1440) + attendanceAdvanceMinutes?: number; + @IsDateString() @IsNotEmpty() startDate: string; @@ -88,6 +94,12 @@ export class UpdateScheduleDto { @Matches(/^\d{2}:\d{2}$/) endTime?: string; + @IsOptional() + @IsInt() + @Min(0) + @Max(1440) + attendanceAdvanceMinutes?: number; + @IsOptional() @IsDateString() startDate?: string; diff --git a/apps/server/src/schedules/schedules.service.spec.ts b/apps/server/src/schedules/schedules.service.spec.ts index cbdc64d..b4ac268 100644 --- a/apps/server/src/schedules/schedules.service.spec.ts +++ b/apps/server/src/schedules/schedules.service.spec.ts @@ -110,15 +110,47 @@ describe('SchedulesService — checkConflict', () => { ).rejects.toThrow(ConflictException); }); - it('same classroom + same weekday + non-overlapping times → no conflict', async () => { + it('rejects schedules separated by less than 10 minutes', async () => { + const qb = mockQueryBuilder([ + { id: 1, subject: '数学', startTime: '08:00', endTime: '10:00' } as ClassSchedule, + ]); + const rentalQb = mockQueryBuilder([]); + (scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb); + (rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb); + + await expect( + service.checkConflict(1, 3, '10:09', '12:00', '2026-03-01', '2026-06-30'), + ).rejects.toThrow('排课之间必须至少间隔 10 分钟'); + expect(qb.andWhere).toHaveBeenCalledWith('cs.endTime > :bufferedStartTime', { + bufferedStartTime: '09:59', + }); + }); + + it('allows adjacent schedules when there is exactly a 10-minute gap', async () => { const qb = mockQueryBuilder([]); const rentalQb = mockQueryBuilder([]); (scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb); (rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb); await expect( - service.checkConflict(1, 3, '10:00', '12:00', '2026-03-01', '2026-06-30'), + service.checkConflict(1, 3, '10:10', '12:00', '2026-03-01', '2026-06-30'), ).resolves.toEqual([]); + expect(qb.andWhere).toHaveBeenCalledWith('cs.endTime > :bufferedStartTime', { + bufferedStartTime: '10:00', + }); + }); + + it('reserves 10 minutes after the new schedule when checking the next schedule', async () => { + const qb = mockQueryBuilder([]); + const rentalQb = mockQueryBuilder([]); + (scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb); + (rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb); + + await service.checkConflict(1, 3, '08:00', '09:50', '2026-03-01', '2026-06-30'); + + expect(qb.andWhere).toHaveBeenCalledWith('cs.startTime < :bufferedEndTime', { + bufferedEndTime: '10:00', + }); }); it('same classroom + same weekday + overlapping times but disjoint date ranges → no conflict', async () => { diff --git a/apps/server/src/schedules/schedules.service.ts b/apps/server/src/schedules/schedules.service.ts index ae0b650..376705a 100644 --- a/apps/server/src/schedules/schedules.service.ts +++ b/apps/server/src/schedules/schedules.service.ts @@ -23,6 +23,16 @@ import { WeeklyViewQueryDto, } from './dto/schedule.dto'; +const SCHEDULE_GAP_MINUTES = 10; + +function shiftTime(time: string, minutes: number): string { + const [hours, minutePart] = time.split(':').map(Number); + const shifted = Math.min(24 * 60, Math.max(0, hours * 60 + minutePart + minutes)); + const shiftedHours = Math.floor(shifted / 60); + const shiftedMinutes = shifted % 60; + return `${String(shiftedHours).padStart(2, '0')}:${String(shiftedMinutes).padStart(2, '0')}`; +} + @Injectable() export class SchedulesService { constructor( @@ -58,6 +68,7 @@ export class SchedulesService { weekDay: schedule.weekDay, startTime: schedule.startTime, endTime: schedule.endTime, + attendanceAdvanceMinutes: schedule.attendanceAdvanceMinutes, startDate: schedule.startDate, endDate: schedule.endDate, subject: '已占用', @@ -243,13 +254,16 @@ export class SchedulesService { endDate: string, excludeId?: number, ) { + // 为教室换场、整理和人员进出预留时间;恰好间隔 10 分钟允许排课。 + const bufferedStartTime = shiftTime(startTime, -SCHEDULE_GAP_MINUTES); + const bufferedEndTime = shiftTime(endTime, SCHEDULE_GAP_MINUTES); const qb = this.scheduleRepo .createQueryBuilder('cs') .where('cs.classroomId = :classroomId', { classroomId }) .andWhere('cs.weekDay = :weekDay', { weekDay }) .andWhere('cs.status = :status', { status: 'active' }) - .andWhere('cs.startTime < :endTime', { endTime }) - .andWhere('cs.endTime > :startTime', { startTime }) + .andWhere('cs.startTime < :bufferedEndTime', { bufferedEndTime }) + .andWhere('cs.endTime > :bufferedStartTime', { bufferedStartTime }) .andWhere('cs.startDate <= :endDate', { endDate }) .andWhere('cs.endDate >= :startDate', { startDate }); @@ -258,7 +272,7 @@ export class SchedulesService { const conflicts = await qb.getMany(); if (conflicts.length > 0) { throw new ConflictException( - `该时间段与已有排课冲突: ${conflicts.map((c) => `${c.subject}(${c.startTime}-${c.endTime})`).join(', ')}`, + `排课之间必须至少间隔 ${SCHEDULE_GAP_MINUTES} 分钟,与以下排课时间过近: ${conflicts.map((c) => `${c.subject}(${c.startTime}-${c.endTime})`).join(', ')}`, ); } diff --git a/apps/server/src/students/dto/student.dto.ts b/apps/server/src/students/dto/student.dto.ts index bc08e95..d6d1904 100644 --- a/apps/server/src/students/dto/student.dto.ts +++ b/apps/server/src/students/dto/student.dto.ts @@ -1,4 +1,5 @@ -import { IsString, IsOptional, IsEnum, IsInt } from 'class-validator'; +import { IsBoolean, IsIn, IsInt, IsOptional, IsString } from 'class-validator'; +import { Transform, Type } from 'class-transformer'; export class CreateStudentDto { @IsString() @@ -82,6 +83,31 @@ export class UpdateStudentDto { supervisor?: string; @IsOptional() - @IsEnum(['active', 'graduated', 'withdrawn']) + @IsIn(['active', 'graduated', 'withdrawn']) status?: string; } + +export class QueryStudentDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsIn(['active', 'graduated', 'withdrawn', 'archived']) + status?: string; + + @IsOptional() + @Transform(({ value }) => { + if (typeof value === 'boolean') return value; + if (value === 'true' || value === '1') return true; + if (value === 'false' || value === '0') return false; + return value; + }) + @IsBoolean() + includeArchived?: boolean; + + @IsOptional() + @Type(() => Number) + @IsInt() + organizationId?: number; +} diff --git a/apps/server/src/students/students.controller.ts b/apps/server/src/students/students.controller.ts index 3cb20fd..cf4e6e9 100644 --- a/apps/server/src/students/students.controller.ts +++ b/apps/server/src/students/students.controller.ts @@ -13,6 +13,7 @@ import { UseInterceptors, UploadedFile, Inject, + ParseIntPipe, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; @@ -21,7 +22,7 @@ import { ClassTeacher } from '../entities/class-teacher.entity'; import { FileInterceptor } from '@nestjs/platform-express'; import type { Response } from 'express'; import { StudentsService } from './students.service'; -import { CreateStudentDto, UpdateStudentDto } from './dto/student.dto'; +import { CreateStudentDto, QueryStudentDto, UpdateStudentDto } from './dto/student.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { extractRequestInfo } from '../common/request-utils'; @@ -61,10 +62,7 @@ export class StudentsController { @Get() @RequirePermission('student:view') async findAll( - @Query('name') name: string | undefined, - @Query('status') status: string | undefined, - @Query('includeArchived') includeArchived: string | undefined, - @Query('organizationId') organizationId: string | undefined, + @Query() query: QueryStudentDto, @Request() req: AuthenticatedRequest, ) { const classIds = await this.service.getAccessibleClassIds( @@ -72,12 +70,7 @@ export class StudentsController { this.canManageAllStudents(req), ); return this.service.findAll( - { - name, - status, - includeArchived: includeArchived === 'true', - organizationId: organizationId ? +organizationId : undefined, - }, + query, classIds, ); } @@ -192,8 +185,8 @@ export class StudentsController { @Get(':id') @RequirePermission('student:view') - findOne(@Param('id') id: string) { - return this.service.findOne(+id); + findOne(@Param('id', ParseIntPipe) id: number) { + return this.service.findOne(id); } @Post() @@ -217,15 +210,15 @@ export class StudentsController { @Put(':id') @RequirePermission('student:edit') - async update(@Param('id') id: string, @Body() dto: UpdateStudentDto, @Request() req: any) { + async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateStudentDto, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.service.update(+id, dto); + const result = await this.service.update(id, dto); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '学生管理', action: '编辑学生', - targetId: +id, + targetId: id, targetType: 'student', detail: JSON.stringify(dto), ipAddress, @@ -236,15 +229,15 @@ export class StudentsController { @Delete(':id') @RequirePermission('student:delete') - async remove(@Param('id') id: string, @Request() req: any) { + async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.service.remove(+id); + const result = await this.service.remove(id); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '学生管理', action: '删除学生', - targetId: +id, + targetId: id, targetType: 'student', ipAddress, userAgent, @@ -271,15 +264,15 @@ export class StudentsController { @Put(':id/restore') @RequirePermission('student:edit') - async restore(@Param('id') id: string, @Request() req: any) { + async restore(@Param('id', ParseIntPipe) id: number, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); - const result = await this.service.restore(+id); + const result = await this.service.restore(id); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '学生管理', action: '恢复学生', - targetId: +id, + targetId: id, targetType: 'student', ipAddress, userAgent, @@ -403,7 +396,7 @@ export class StudentsController { @Get(':id/compare-classes') @RequirePermission('student:view') - compareClasses(@Param('id') id: string) { - return this.service.compareClasses(+id); + compareClasses(@Param('id', ParseIntPipe) id: number) { + return this.service.compareClasses(id); } } diff --git a/apps/server/src/sync/schedule-sync.service.spec.ts b/apps/server/src/sync/schedule-sync.service.spec.ts index 3e50164..7099fdf 100644 --- a/apps/server/src/sync/schedule-sync.service.spec.ts +++ b/apps/server/src/sync/schedule-sync.service.spec.ts @@ -462,3 +462,90 @@ describe('ScheduleSyncService — all shifts fail', () => { }); }); +describe('ScheduleSyncService — multiple lessons per student per day', () => { + it('combines daily lessons into one DingTalk shift with multiple sections', async () => { + const scheduleRepo = { + find: jest.fn().mockResolvedValue([ + { + id: 2, + classId: 10, + classroomId: 1, + weekDay: 1, + startTime: '22:10', + endTime: '23:10', + startDate: '2026-07-13', + endDate: '2026-07-13', + status: 'active', + } as ClassSchedule, + { + id: 1, + classId: 10, + classroomId: 2, + weekDay: 1, + startTime: '20:00', + endTime: '21:00', + startDate: '2026-07-13', + endDate: '2026-07-13', + status: 'active', + } as ClassSchedule, + ]), + }; + const classStudentRepo = { + find: jest.fn().mockResolvedValue([{ classId: 10, studentId: 20, status: 'active' }]), + }; + const mappingRepo = { + find: jest.fn().mockResolvedValue([{ studentId: 20, dingUserId: 'student-1' }]), + }; + const classRepo = { + find: jest.fn().mockResolvedValue([{ id: 10, name: '冲刺一班' }]), + }; + const scheduleUsers = jest.fn().mockResolvedValue(undefined); + const upsertShift = jest.fn().mockResolvedValue(2022); + const dingTalkService = { + queryShifts: jest.fn().mockResolvedValue([]), + upsertShift, + queryAttendanceGroups: jest.fn().mockResolvedValue([ + { group_id: 1495610001, group_name: '排课_冲刺一班', type: 'TURN', member_count: 1 }, + ]), + updateAttendanceGroup: jest.fn().mockResolvedValue(undefined), + createAttendanceGroup: jest.fn(), + scheduleUsers, + }; + + const service = new ScheduleSyncService( + scheduleRepo as never, + classStudentRepo as never, + mappingRepo as never, + classRepo as never, + dingTalkService as never, + ); + + const result = await service.syncAll('2026-07-13', 1); + + expect(upsertShift).toHaveBeenCalledTimes(1); + expect(upsertShift).toHaveBeenCalledWith(expect.objectContaining({ + name: '冲刺一班_20:00-21:00+22:10-23:10', + sections: [ + expect.objectContaining({ + times: expect.arrayContaining([ + expect.objectContaining({ check_type: 'OnDuty', check_time: '1970-01-01 20:00:00' }), + expect.objectContaining({ check_type: 'OffDuty', check_time: '1970-01-01 21:00:00' }), + ]), + }), + expect.objectContaining({ + times: expect.arrayContaining([ + expect.objectContaining({ check_type: 'OnDuty', check_time: '1970-01-01 22:10:00' }), + expect.objectContaining({ check_type: 'OffDuty', check_time: '1970-01-01 23:10:00' }), + ]), + }), + ], + })); + expect(scheduleUsers).toHaveBeenCalledTimes(1); + expect(scheduleUsers.mock.calls[0][1]).toEqual([ + expect.objectContaining({ userid: 'student-1', shift_id: 2022 }), + ]); + expect(result.syncedItems).toBe(1); + expect(result.failedBatchCount).toBe(0); + }); +}); + diff --git a/apps/server/src/sync/schedule-sync.service.ts b/apps/server/src/sync/schedule-sync.service.ts index 339b4f0..d7fc710 100644 --- a/apps/server/src/sync/schedule-sync.service.ts +++ b/apps/server/src/sync/schedule-sync.service.ts @@ -1,14 +1,22 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In } from 'typeorm'; -import { - ClassSchedule, - ClassStudent, - StudentDingMapping, - Class, -} from '../entities'; +import { ClassSchedule, ClassStudent, StudentDingMapping, Class } from '../entities'; import { DingTalkService, DingTalkScheduleItem } from '../integration/dingtalk.service'; +interface DailySchedulePeriod { + startTime: string; + endTime: string; + scheduleId: number; +} + +interface DailySchedulePlan { + classId: number; + date: string; + shiftKey: string; + periods: DailySchedulePeriod[]; +} + /** 单次排班同步的结果 */ export interface ScheduleSyncResult { /** 参与同步的排课记录数 */ @@ -89,9 +97,14 @@ export class ScheduleSyncService { const endDate = this.addDays(startDate, days); const empty: ScheduleSyncResult = { - scheduleCount: 0, shiftCount: 0, groupCount: 0, - syncedItems: 0, skippedNoMapping: 0, - failedBatchCount: 0, failedItems: 0, errors: [], + scheduleCount: 0, + shiftCount: 0, + groupCount: 0, + syncedItems: 0, + skippedNoMapping: 0, + failedBatchCount: 0, + failedItems: 0, + errors: [], groups: [], }; @@ -110,63 +123,77 @@ export class ScheduleSyncService { const classDingUsers = await this.buildClassDingUserMap(classIds); const classNameMap = await this.loadClassNames(classIds); - // ── Step 3: 班次(按时间段去重,班次列表只查一次) ── - const shiftKey = (classId: number, start: string, end: string) => - `${classId}|${start}-${end}`; + // ── Step 3: 将每天的多节课合并成一个钉钉班次 ── + // 钉钉要求每人每天只能写入一条排班,因此同一天的多节课必须作为 + // 同一个班次的多个 sections 写入,不能拆成多条 schedule item。 + const dailyPlans = this.buildDailySchedulePlans(schedules, startDate, endDate); const uniqueShifts = new Map< string, - { className: string; startTime: string; endTime: string } + { className: string; periods: DailySchedulePeriod[] } >(); - const shiftScheduleCount = new Map(); - for (const schedule of schedules) { - const classId = schedule.classId as number; - const key = shiftKey(classId, schedule.startTime, schedule.endTime); - if (!uniqueShifts.has(key)) { - uniqueShifts.set(key, { - className: classNameMap.get(classId) || `班级${classId}`, - startTime: schedule.startTime, - endTime: schedule.endTime, + const shiftPlanCount = new Map(); + for (const plan of dailyPlans) { + if (!uniqueShifts.has(plan.shiftKey)) { + uniqueShifts.set(plan.shiftKey, { + className: classNameMap.get(plan.classId) || `班级${plan.classId}`, + periods: plan.periods, }); } - shiftScheduleCount.set(key, (shiftScheduleCount.get(key) || 0) + 1); + shiftPlanCount.set(plan.shiftKey, (shiftPlanCount.get(plan.shiftKey) || 0) + 1); } + const existingShifts = await this.dingTalkService.queryShifts(opUserId); - const shiftByName = new Map(existingShifts.map((s) => [s.name, s.id])); - const timeToShiftId = new Map(); + const shiftByName = new Map(existingShifts.map((shift) => [shift.name, shift.id])); + const planToShiftId = new Map(); const errors: string[] = []; let failedBatchCount = 0; let failedItems = 0; let shiftCount = 0; - for (const [key, { className, startTime, endTime }] of uniqueShifts) { - const shiftName = `${className}_${startTime}-${endTime}`; + for (const [key, { className, periods }] of uniqueShifts) { + const periodLabel = periods + .map((period) => `${period.startTime}-${period.endTime}`) + .join('+'); + const shiftName = `${className}_${periodLabel}`; try { let shiftId = shiftByName.get(shiftName); const shiftParams = { ...(shiftId === undefined ? {} : { id: shiftId }), name: shiftName, owner: opUserId, - sections: [{ + sections: periods.map((period) => ({ times: [ - { check_type: 'OnDuty' as const, across: 0, check_time: `1970-01-01 ${startTime}:00`, free_check: false }, - { check_type: 'OffDuty' as const, across: 0, check_time: `1970-01-01 ${endTime}:00`, free_check: false }, + { + check_type: 'OnDuty' as const, + across: 0, + check_time: `1970-01-01 ${period.startTime}:00`, + free_check: false, + }, + { + check_type: 'OffDuty' as const, + across: 0, + check_time: `1970-01-01 ${period.endTime}:00`, + free_check: false, + }, ], - }], + })), setting: { is_flexible: false, serious_late_minutes: -1, - absenteeism_late_minutes: this.minutesBetween(startTime, endTime), + absenteeism_late_minutes: Math.max( + ...periods.map((period) => this.minutesBetween(period.startTime, period.endTime)), + ), }, }; shiftId = await this.dingTalkService.upsertShift(shiftParams); shiftByName.set(shiftName, shiftId); - timeToShiftId.set(key, shiftId); + planToShiftId.set(key, shiftId); shiftCount++; } catch (e) { const msg = `创建班次 ${shiftName} 失败: ${(e as Error).message}`; this.logger.error(msg); errors.push(msg); failedBatchCount++; - failedItems += shiftScheduleCount.get(key) || 0; + failedItems += shiftPlanCount.get(key) || 0; } } @@ -176,10 +203,15 @@ export class ScheduleSyncService { // ── Step 5: 按班级同步 ── const schedulesByClass = new Map(); - for (const s of schedules) { - const cid = s.classId as number; - if (!schedulesByClass.has(cid)) schedulesByClass.set(cid, []); - schedulesByClass.get(cid)!.push(s); + for (const schedule of schedules) { + const classId = schedule.classId as number; + if (!schedulesByClass.has(classId)) schedulesByClass.set(classId, []); + schedulesByClass.get(classId)!.push(schedule); + } + const dailyPlansByClass = new Map(); + for (const plan of dailyPlans) { + if (!dailyPlansByClass.has(plan.classId)) dailyPlansByClass.set(plan.classId, []); + dailyPlansByClass.get(plan.classId)!.push(plan); } let syncedItems = 0; @@ -196,11 +228,12 @@ export class ScheduleSyncService { continue; } - // 该班级用到的班次 + const classDailyPlans = dailyPlansByClass.get(classId) ?? []; + // 该班级在同步日期范围内用到的合并班次 const classShiftIds = new Set(); - for (const s of classSchedules) { - const sid = timeToShiftId.get(shiftKey(classId, s.startTime, s.endTime)); - if (sid) classShiftIds.add(sid); + for (const plan of classDailyPlans) { + const shiftId = planToShiftId.get(plan.shiftKey); + if (shiftId) classShiftIds.add(shiftId); } if (classShiftIds.size === 0) { this.logger.warn(`班级 ${className} 无可用班次,跳过(班次创建已计入 failure)`); @@ -208,9 +241,7 @@ export class ScheduleSyncService { } // 先展开排班以计算受影响条数 - const items = this.expandSchedules( - classSchedules, dingUserIds, timeToShiftId, startDate, endDate, - ); + const items = this.expandDailySchedulePlans(classDailyPlans, dingUserIds, planToShiftId); if (items.length === 0) { this.logger.warn(`班级 ${className} 无可用班次匹配,跳过`); @@ -227,7 +258,11 @@ export class ScheduleSyncService { name: groupName, type: 'TURN' as const, owner: opUserId, - members: dingUserIds.map((uid) => ({ role: 'Attendance', type: 'StaffMember' as const, user_id: uid })), + members: dingUserIds.map((uid) => ({ + role: 'Attendance', + type: 'StaffMember' as const, + user_id: uid, + })), shift_ids: [...classShiftIds], enable_emp_select_class: true, disable_check_without_schedule: false, @@ -276,8 +311,8 @@ export class ScheduleSyncService { this.logger.log( `排班同步完成: ${schedules.length} 条排课 → ${syncedItems} 条钉钉排班, ` + - `${shiftCount} 班次, ${groupCount} 考勤组, 跳过 ${skippedNoMapping} 条无映射` + - (failedBatchCount > 0 ? `, ${failedBatchCount} 批写入失败 (${failedItems} 条)` : ''), + `${shiftCount} 班次, ${groupCount} 考勤组, 跳过 ${skippedNoMapping} 条无映射` + + (failedBatchCount > 0 ? `, ${failedBatchCount} 批写入失败 (${failedItems} 条)` : ''), ); return { @@ -333,53 +368,87 @@ export class ScheduleSyncService { } /** - * 将排课记录展开为每个学生的每日排班数组。 - * 每条 ClassSchedule(weekDay, startDate-endDate) × 班级每个学生 → - * 该日期范围内所有 weekDay 对应日期的排班。 + * 把本地排课转换为“班级 + 日期”的日排班计划。 + * 同一天相同时间段会去重,多节课按开始时间排序并合并为一个钉钉班次。 */ - private expandSchedules( + private buildDailySchedulePlans( schedules: ClassSchedule[], - dingUserIds: string[], - timeToShiftId: Map, syncFrom: string, syncTo: string, - ): DingTalkScheduleItem[] { - const seen = new Set(); - const items: DingTalkScheduleItem[] = []; + ): DailySchedulePlan[] { + const periodMapByClassDate = new Map>(); const fromDate = new Date(syncFrom); const toDate = new Date(syncTo); - // 预计算日期范围内每一天是星期几(周日=7) - const dateWeekDays = new Map(); - for (let d = new Date(fromDate); d <= toDate; d.setDate(d.getDate() + 1)) { - const dateStr = d.toISOString().slice(0, 10); - dateWeekDays.set(dateStr, d.getDay() === 0 ? 7 : d.getDay()); - } + for (let date = new Date(fromDate); date <= toDate; date.setDate(date.getDate() + 1)) { + const dateStr = date.toISOString().slice(0, 10); + const weekDay = date.getDay() === 0 ? 7 : date.getDay(); - for (const s of schedules) { - const shiftId = timeToShiftId.get(`${s.classId}|${s.startTime}-${s.endTime}`); - if (!shiftId) continue; + for (const schedule of schedules) { + if (schedule.classId == null || schedule.weekDay !== weekDay) continue; + if (dateStr < schedule.startDate || dateStr > schedule.endDate) continue; - const scheduleStart = s.startDate > syncFrom ? s.startDate : syncFrom; - const scheduleEnd = s.endDate < syncTo ? s.endDate : syncTo; - - for (const [dateStr, weekDay] of dateWeekDays) { - if (dateStr < scheduleStart || dateStr > scheduleEnd) continue; - if (weekDay !== s.weekDay) continue; - - const workDate = new Date(dateStr + 'T00:00:00+08:00').getTime(); - for (const userid of dingUserIds) { - const dedupKey = `${userid}|${workDate}|${shiftId}`; - if (seen.has(dedupKey)) continue; - seen.add(dedupKey); - items.push({ userid, work_date: workDate, shift_id: shiftId, is_rest: false }); + const classDateKey = `${schedule.classId}|${dateStr}`; + if (!periodMapByClassDate.has(classDateKey)) { + periodMapByClassDate.set(classDateKey, new Map()); + } + const periods = periodMapByClassDate.get(classDateKey)!; + const periodKey = `${schedule.startTime}-${schedule.endTime}`; + const existing = periods.get(periodKey); + if (!existing || schedule.id < existing.scheduleId) { + periods.set(periodKey, { + startTime: schedule.startTime, + endTime: schedule.endTime, + scheduleId: schedule.id, + }); } } } - return items; + const plans: DailySchedulePlan[] = []; + for (const [classDateKey, periodMap] of periodMapByClassDate) { + const separator = classDateKey.indexOf('|'); + const classId = Number(classDateKey.slice(0, separator)); + const date = classDateKey.slice(separator + 1); + const periods = [...periodMap.values()].sort( + (left, right) => + left.startTime.localeCompare(right.startTime) || + left.endTime.localeCompare(right.endTime) || + left.scheduleId - right.scheduleId, + ); + const periodSignature = periods + .map((period) => `${period.startTime}-${period.endTime}`) + .join('+'); + plans.push({ + classId, + date, + shiftKey: `${classId}|${periodSignature}`, + periods, + }); + } + + return plans.sort( + (left, right) => left.date.localeCompare(right.date) || left.classId - right.classId, + ); } + /** 每个学生每天仅生成一条钉钉排班,shift 内可包含多个课程卡段。 */ + private expandDailySchedulePlans( + plans: DailySchedulePlan[], + dingUserIds: string[], + planToShiftId: Map, + ): DingTalkScheduleItem[] { + const items: DingTalkScheduleItem[] = []; + for (const plan of plans) { + const shiftId = planToShiftId.get(plan.shiftKey); + if (!shiftId) continue; + const workDate = new Date(`${plan.date}T00:00:00+08:00`).getTime(); + for (const userid of dingUserIds) { + items.push({ userid, work_date: workDate, shift_id: shiftId, is_rest: false }); + } + } + return items; + } private minutesBetween(startTime: string, endTime: string): number { const [startHour, startMinute] = startTime.split(':').map(Number);