forked from wangziqi/gongxue-base
feat: improve attendance scheduling and API validation
This commit is contained in:
@@ -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)));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user