fix: 归档删除改为软删除
This commit is contained in:
@@ -28,6 +28,16 @@ describe('schedule attendance window validation', () => {
|
||||
const errors = await validate(dto);
|
||||
expect(errors.some((error) => error.property === 'attendanceAdvanceMinutes')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts only supported schedule statuses when updating', async () => {
|
||||
const inactive = Object.assign(new UpdateScheduleDto(), { status: 'inactive' });
|
||||
const cancelled = Object.assign(new UpdateScheduleDto(), { status: 'cancelled' });
|
||||
const paused = Object.assign(new UpdateScheduleDto(), { status: 'paused' });
|
||||
|
||||
expect((await validate(inactive)).some((error) => error.property === 'status')).toBe(false);
|
||||
expect((await validate(cancelled)).some((error) => error.property === 'status')).toBe(false);
|
||||
expect((await validate(paused)).some((error) => error.property === 'status')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('schedule notes validation', () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Min,
|
||||
Max,
|
||||
MaxLength,
|
||||
IsIn,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
@@ -133,6 +134,11 @@ export class UpdateScheduleDto {
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(['active', 'inactive', 'cancelled'])
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export class QueryScheduleDto {
|
||||
|
||||
@@ -258,7 +258,7 @@ export class SchedulesController {
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '排课管理',
|
||||
action: '删除排课',
|
||||
action: '停用排课',
|
||||
targetId: +id,
|
||||
targetType: 'class-schedule',
|
||||
ipAddress,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { Repository } from 'typeorm';
|
||||
import { SchedulesService } from './schedules.service';
|
||||
import { ClassSchedule, ScheduleType } from '../entities/class-schedule.entity';
|
||||
@@ -274,28 +274,48 @@ describe('SchedulesService — getClassroomOccupancy', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('SchedulesService — remove', () => {
|
||||
describe('SchedulesService — remove/update status', () => {
|
||||
let service: SchedulesService;
|
||||
let scheduleRepo: jest.Mocked<Pick<Repository<ClassSchedule>, 'findOne' | 'remove'>>;
|
||||
let scheduleRepo: jest.Mocked<
|
||||
Pick<Repository<ClassSchedule>, 'findOne' | 'remove' | 'update' | 'createQueryBuilder'>
|
||||
>;
|
||||
let classroomRepo: jest.Mocked<Pick<Repository<Classroom>, 'find' | 'findOne'>>;
|
||||
let classTeacherRepo: jest.Mocked<Pick<Repository<ClassTeacher>, 'find' | 'findOne'>>;
|
||||
let rentalRepo: jest.Mocked<Pick<Repository<ClassroomRental>, 'createQueryBuilder'>>;
|
||||
let attendanceSessionRepo: jest.Mocked<Pick<Repository<AttendanceSession>, 'count'>>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const scheduleRepoMock = {
|
||||
findOne: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
update: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
const classroomRepoMock = {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
findOne: jest.fn().mockResolvedValue({ id: 1, status: 'available' }),
|
||||
};
|
||||
const classTeacherRepoMock = {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
findOne: jest.fn().mockResolvedValue({ id: 1 }),
|
||||
};
|
||||
const rentalRepoMock = { createQueryBuilder: jest.fn() };
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
SchedulesService,
|
||||
{
|
||||
provide: getRepositoryToken(ClassSchedule),
|
||||
useValue: { findOne: jest.fn(), remove: jest.fn() },
|
||||
useValue: scheduleRepoMock,
|
||||
},
|
||||
{ provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } },
|
||||
{ provide: getRepositoryToken(Classroom), useValue: { find: jest.fn().mockResolvedValue([]) } },
|
||||
{ provide: getRepositoryToken(Classroom), useValue: classroomRepoMock },
|
||||
{
|
||||
provide: getRepositoryToken(ClassroomRental),
|
||||
useValue: { createQueryBuilder: jest.fn() },
|
||||
useValue: rentalRepoMock,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(ClassTeacher),
|
||||
useValue: { find: jest.fn().mockResolvedValue([]) },
|
||||
useValue: classTeacherRepoMock,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(AttendanceSession),
|
||||
@@ -306,29 +326,32 @@ describe('SchedulesService — remove', () => {
|
||||
|
||||
service = module.get<SchedulesService>(SchedulesService);
|
||||
scheduleRepo = module.get(getRepositoryToken(ClassSchedule));
|
||||
classroomRepo = module.get(getRepositoryToken(Classroom));
|
||||
classTeacherRepo = module.get(getRepositoryToken(ClassTeacher));
|
||||
rentalRepo = module.get(getRepositoryToken(ClassroomRental));
|
||||
attendanceSessionRepo = module.get(getRepositoryToken(AttendanceSession));
|
||||
});
|
||||
|
||||
it('deletes a schedule with no attendance sessions', async () => {
|
||||
const schedule = { id: 1, subject: '数学' } as ClassSchedule;
|
||||
it('disables a schedule instead of deleting it', async () => {
|
||||
const schedule = { id: 1, subject: '数学', status: 'active' } as ClassSchedule;
|
||||
(scheduleRepo.findOne as jest.Mock).mockResolvedValue(schedule);
|
||||
(scheduleRepo.remove as jest.Mock).mockResolvedValue(schedule);
|
||||
(attendanceSessionRepo.count as jest.Mock).mockResolvedValue(0);
|
||||
(scheduleRepo.update as jest.Mock).mockResolvedValue({ affected: 1 });
|
||||
|
||||
const result = await service.remove(1);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(result).toEqual({ success: true, message: '排课已停用(历史考勤记录已保留)' });
|
||||
expect(scheduleRepo.findOne).toHaveBeenCalledWith({ where: { id: 1 } });
|
||||
expect(scheduleRepo.remove).toHaveBeenCalledWith(schedule);
|
||||
expect(scheduleRepo.update).toHaveBeenCalledWith(1, { status: 'inactive' });
|
||||
expect(scheduleRepo.remove).not.toHaveBeenCalled();
|
||||
expect(attendanceSessionRepo.count).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects deletion when attendance sessions exist', async () => {
|
||||
const schedule = { id: 2, subject: '英语' } as ClassSchedule;
|
||||
it('rejects disabling an already inactive schedule', async () => {
|
||||
const schedule = { id: 2, subject: '英语', status: 'inactive' } as ClassSchedule;
|
||||
(scheduleRepo.findOne as jest.Mock).mockResolvedValue(schedule);
|
||||
(scheduleRepo.remove as jest.Mock).mockResolvedValue(schedule);
|
||||
(attendanceSessionRepo.count as jest.Mock).mockResolvedValue(3);
|
||||
|
||||
await expect(service.remove(2)).rejects.toThrow(ConflictException);
|
||||
await expect(service.remove(2)).rejects.toThrow('排课已停用');
|
||||
expect(scheduleRepo.update).not.toHaveBeenCalled();
|
||||
expect(scheduleRepo.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -338,6 +361,84 @@ describe('SchedulesService — remove', () => {
|
||||
await expect(service.remove(999)).rejects.toThrow('排课记录不存在');
|
||||
expect(scheduleRepo.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
it('disables a schedule without checking conflicts or deleting history', async () => {
|
||||
const schedule = {
|
||||
id: 3,
|
||||
classId: 1,
|
||||
classroomId: 10,
|
||||
weekDay: 2,
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-31',
|
||||
subject: '英语',
|
||||
teacherId: 5,
|
||||
status: 'active',
|
||||
} as ClassSchedule;
|
||||
(scheduleRepo.findOne as jest.Mock)
|
||||
.mockResolvedValueOnce(schedule)
|
||||
.mockResolvedValueOnce({ ...schedule, status: 'inactive' });
|
||||
|
||||
const result = await service.update(3, { status: 'inactive' });
|
||||
|
||||
expect(result).toMatchObject({ id: 3, status: 'inactive' });
|
||||
expect(scheduleRepo.update).toHaveBeenCalledWith(3, { status: 'inactive' });
|
||||
expect(scheduleRepo.createQueryBuilder).not.toHaveBeenCalled();
|
||||
expect(rentalRepo.createQueryBuilder).not.toHaveBeenCalled();
|
||||
expect(classroomRepo.findOne).not.toHaveBeenCalled();
|
||||
expect(classTeacherRepo.findOne).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('checks classroom availability and conflicts when reactivating a schedule', async () => {
|
||||
const schedule = {
|
||||
id: 4,
|
||||
classId: 1,
|
||||
classroomId: 10,
|
||||
weekDay: 2,
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-31',
|
||||
subject: '英语',
|
||||
teacherId: 5,
|
||||
status: 'inactive',
|
||||
} as ClassSchedule;
|
||||
(scheduleRepo.findOne as jest.Mock)
|
||||
.mockResolvedValueOnce(schedule)
|
||||
.mockResolvedValueOnce({ ...schedule, status: 'active' });
|
||||
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder<ClassSchedule>([]));
|
||||
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder<ClassroomRental>([]));
|
||||
|
||||
await expect(service.update(4, { status: 'active' })).resolves.toMatchObject({
|
||||
id: 4,
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
expect(classroomRepo.findOne).toHaveBeenCalledWith({ where: { id: 10 } });
|
||||
expect(scheduleRepo.createQueryBuilder).toHaveBeenCalled();
|
||||
expect(scheduleRepo.update).toHaveBeenCalledWith(4, { status: 'active' });
|
||||
});
|
||||
|
||||
it('rejects invalid schedule statuses', async () => {
|
||||
const schedule = {
|
||||
id: 5,
|
||||
classId: 1,
|
||||
classroomId: 10,
|
||||
weekDay: 2,
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-31',
|
||||
subject: '英语',
|
||||
status: 'active',
|
||||
} as ClassSchedule;
|
||||
(scheduleRepo.findOne as jest.Mock).mockResolvedValue(schedule);
|
||||
|
||||
await expect(service.update(5, { status: 'paused' })).rejects.toThrow(BadRequestException);
|
||||
expect(scheduleRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SchedulesService — range boundaries', () => {
|
||||
|
||||
@@ -24,6 +24,10 @@ import {
|
||||
} from './dto/schedule.dto';
|
||||
|
||||
const SCHEDULE_GAP_MINUTES = 10;
|
||||
const ACTIVE_SCHEDULE_STATUS = 'active';
|
||||
const INACTIVE_SCHEDULE_STATUSES = ['inactive', 'cancelled'] as const;
|
||||
type ScheduleStatus = typeof ACTIVE_SCHEDULE_STATUS | (typeof INACTIVE_SCHEDULE_STATUSES)[number];
|
||||
const SCHEDULE_STATUSES: readonly ScheduleStatus[] = [ACTIVE_SCHEDULE_STATUS, ...INACTIVE_SCHEDULE_STATUSES];
|
||||
|
||||
function shiftTime(time: string, minutes: number): string {
|
||||
const [hours, minutePart] = time.split(':').map(Number);
|
||||
@@ -171,6 +175,12 @@ export class SchedulesService {
|
||||
return schedule;
|
||||
}
|
||||
|
||||
private assertValidScheduleStatus(status: string): asserts status is ScheduleStatus {
|
||||
if (!SCHEDULE_STATUSES.includes(status as ScheduleStatus)) {
|
||||
throw new BadRequestException('排课状态无效');
|
||||
}
|
||||
}
|
||||
|
||||
private async assertClassroomAvailable(classroomId: number) {
|
||||
const classroom = await this.classroomRepo.findOne({ where: { id: classroomId } });
|
||||
if (!classroom) throw new NotFoundException('教室不存在');
|
||||
@@ -216,11 +226,12 @@ export class SchedulesService {
|
||||
const existing = await this.scheduleRepo.findOne({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('排课记录不存在');
|
||||
|
||||
// If classroom, weekDay, or times are changing, check conflicts excluding self
|
||||
const nextStatus = dto.status ?? existing.status;
|
||||
this.assertValidScheduleStatus(nextStatus);
|
||||
|
||||
// If classroom, weekDay, or times are changing, check conflicts excluding self.
|
||||
// Inactive/cancelled schedules preserve history but no longer occupy classrooms.
|
||||
const classroomId = dto.classroomId ?? existing.classroomId;
|
||||
if (dto.classroomId !== undefined && dto.classroomId !== existing.classroomId) {
|
||||
await this.assertClassroomAvailable(dto.classroomId);
|
||||
}
|
||||
const weekDay = dto.weekDay ?? existing.weekDay;
|
||||
const startTime = dto.startTime ?? existing.startTime;
|
||||
const endTime = dto.endTime ?? existing.endTime;
|
||||
@@ -228,17 +239,27 @@ export class SchedulesService {
|
||||
const endDate = dto.endDate ?? existing.endDate;
|
||||
this.assertValidScheduleRange(startTime, endTime, startDate, endDate);
|
||||
|
||||
const normalized = await this.normalizeTeacherForSchedule({
|
||||
...dto,
|
||||
classId: dto.classId ?? existing.classId ?? undefined,
|
||||
subject: dto.subject ?? existing.subject,
|
||||
});
|
||||
if (dto.teacherId === undefined && normalized.teacherId !== undefined) {
|
||||
dto.teacherId = normalized.teacherId;
|
||||
if (nextStatus === ACTIVE_SCHEDULE_STATUS) {
|
||||
if (dto.classroomId !== undefined && dto.classroomId !== existing.classroomId) {
|
||||
await this.assertClassroomAvailable(dto.classroomId);
|
||||
} else if (existing.status !== ACTIVE_SCHEDULE_STATUS) {
|
||||
await this.assertClassroomAvailable(classroomId);
|
||||
}
|
||||
}
|
||||
|
||||
if (nextStatus === ACTIVE_SCHEDULE_STATUS) {
|
||||
const normalized = await this.normalizeTeacherForSchedule({
|
||||
...dto,
|
||||
classId: dto.classId ?? existing.classId ?? undefined,
|
||||
subject: dto.subject ?? existing.subject,
|
||||
});
|
||||
if (dto.teacherId === undefined && normalized.teacherId !== undefined) {
|
||||
dto.teacherId = normalized.teacherId;
|
||||
}
|
||||
const teacherId = dto.teacherId ?? existing.teacherId;
|
||||
await this.assertTeacherAssignedToClass(dto.classId ?? existing.classId, teacherId);
|
||||
await this.checkConflict(classroomId, weekDay, startTime, endTime, startDate, endDate, id);
|
||||
}
|
||||
const teacherId = dto.teacherId ?? existing.teacherId;
|
||||
await this.assertTeacherAssignedToClass(dto.classId ?? existing.classId, teacherId);
|
||||
await this.checkConflict(classroomId, weekDay, startTime, endTime, startDate, endDate, id);
|
||||
|
||||
await this.scheduleRepo.update(id, dto);
|
||||
return this.findOne(id);
|
||||
@@ -247,18 +268,12 @@ export class SchedulesService {
|
||||
async remove(id: number) {
|
||||
const schedule = await this.scheduleRepo.findOne({ where: { id } });
|
||||
if (!schedule) throw new NotFoundException('排课记录不存在');
|
||||
|
||||
const sessionCount = await this.attendanceSessionRepo.count({
|
||||
where: { scheduleId: id },
|
||||
});
|
||||
if (sessionCount > 0) {
|
||||
throw new ConflictException(
|
||||
`无法删除已产生 ${sessionCount} 个考勤场次的排课。请先取消或停用排课以保护历史考勤数据。`,
|
||||
);
|
||||
if (schedule.status !== ACTIVE_SCHEDULE_STATUS) {
|
||||
throw new BadRequestException('排课已停用');
|
||||
}
|
||||
|
||||
await this.scheduleRepo.remove(schedule);
|
||||
return { success: true };
|
||||
await this.scheduleRepo.update(id, { status: 'inactive' });
|
||||
return { success: true, message: '排课已停用(历史考勤记录已保留)' };
|
||||
}
|
||||
|
||||
async checkConflict(
|
||||
@@ -277,7 +292,7 @@ export class SchedulesService {
|
||||
.createQueryBuilder('cs')
|
||||
.where('cs.classroomId = :classroomId', { classroomId })
|
||||
.andWhere('cs.weekDay = :weekDay', { weekDay })
|
||||
.andWhere('cs.status = :status', { status: 'active' })
|
||||
.andWhere('cs.status = :status', { status: ACTIVE_SCHEDULE_STATUS })
|
||||
.andWhere('cs.startTime < :bufferedEndTime', { bufferedEndTime })
|
||||
.andWhere('cs.endTime > :bufferedStartTime', { bufferedStartTime })
|
||||
.andWhere('cs.startDate <= :endDate', { endDate })
|
||||
@@ -323,7 +338,7 @@ export class SchedulesService {
|
||||
}
|
||||
|
||||
const schedules = await qb
|
||||
.andWhere('cs.status = :status', { status: 'active' })
|
||||
.andWhere('cs.status = :status', { status: ACTIVE_SCHEDULE_STATUS })
|
||||
.orderBy('cs.weekDay', 'ASC')
|
||||
.addOrderBy('cs.startTime', 'ASC')
|
||||
.getMany();
|
||||
@@ -356,7 +371,7 @@ export class SchedulesService {
|
||||
const qb = this.scheduleRepo
|
||||
.createQueryBuilder('cs')
|
||||
.where('cs.classroomId = :classroomId', { classroomId })
|
||||
.andWhere('cs.status = :status', { status: 'active' })
|
||||
.andWhere('cs.status = :status', { status: ACTIVE_SCHEDULE_STATUS })
|
||||
.andWhere('cs.scheduleType IN (:...scheduleTypes)', {
|
||||
scheduleTypes: ['INTERNAL', 'RENTAL'],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user