fix: 归档删除改为软删除

This commit is contained in:
2026-07-17 14:13:17 +08:00
parent 3131d2e141
commit f7328d670d
49 changed files with 520 additions and 354 deletions

View File

@@ -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', () => {

View File

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

View File

@@ -258,7 +258,7 @@ export class SchedulesController {
userId: req.user?.id,
username: req.user?.username,
module: '排课管理',
action: '删除排课',
action: '停用排课',
targetId: +id,
targetType: 'class-schedule',
ipAddress,

View File

@@ -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', () => {

View File

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