feat: improve attendance scheduling and API validation

This commit is contained in:
2026-07-14 23:12:14 +08:00
parent c75a08affe
commit e45da7f998
33 changed files with 869 additions and 297 deletions

View File

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

View File

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

View File

@@ -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<ClassSchedule>([
{ id: 1, subject: '数学', startTime: '08:00', endTime: '10:00' } as ClassSchedule,
]);
const rentalQb = mockQueryBuilder<ClassroomRental>([]);
(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<ClassSchedule>([]);
const rentalQb = mockQueryBuilder<ClassroomRental>([]);
(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<ClassSchedule>([]);
const rentalQb = mockQueryBuilder<ClassroomRental>([]);
(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 () => {

View File

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