forked from wangziqi/gongxue-base
fix: 归档删除改为软删除
This commit is contained in:
@@ -142,7 +142,7 @@ export class ArchiveController {
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '学生档案',
|
||||
action: '删除报名记录',
|
||||
action: '归档报名记录',
|
||||
targetId: id,
|
||||
targetType: 'student_enrollment',
|
||||
ipAddress,
|
||||
@@ -206,7 +206,7 @@ export class ArchiveController {
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '学生档案',
|
||||
action: '删除考试成绩',
|
||||
action: '归档考试成绩',
|
||||
targetId: id,
|
||||
targetType: 'exam_score',
|
||||
ipAddress,
|
||||
@@ -270,7 +270,7 @@ export class ArchiveController {
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '学生档案',
|
||||
action: '删除学习记录',
|
||||
action: '归档学习记录',
|
||||
targetId: id,
|
||||
targetType: 'learning_record',
|
||||
ipAddress,
|
||||
@@ -353,7 +353,7 @@ export class ArchiveController {
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '学生档案',
|
||||
action: '删除附件',
|
||||
action: '归档附件',
|
||||
targetId: id,
|
||||
targetType: 'archive_attachment',
|
||||
ipAddress,
|
||||
|
||||
@@ -71,11 +71,11 @@ export class ArchiveService {
|
||||
attendances,
|
||||
] = await Promise.all([
|
||||
this.profileRepo.findOne({ where: { studentId } }),
|
||||
this.enrollmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
|
||||
this.examScoreRepo.find({ where: { studentId }, order: { examDate: 'DESC' } }),
|
||||
this.learningRecordRepo.find({ where: { studentId }, order: { recordDate: 'DESC' } }),
|
||||
this.enrollmentRepo.find({ where: { studentId, status: 'active' }, order: { createdAt: 'DESC' } }),
|
||||
this.examScoreRepo.find({ where: { studentId, status: 'active' }, order: { examDate: 'DESC' } }),
|
||||
this.learningRecordRepo.find({ where: { studentId, status: 'active' }, order: { recordDate: 'DESC' } }),
|
||||
this.resultRepo.findOne({ where: { studentId } }),
|
||||
this.attachmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
|
||||
this.attachmentRepo.find({ where: { studentId, status: 'active' }, order: { createdAt: 'DESC' } }),
|
||||
this.attendanceRepo.find({
|
||||
where: { studentId },
|
||||
relations: ['schedule', 'class'],
|
||||
@@ -126,8 +126,9 @@ export class ArchiveService {
|
||||
async deleteEnrollment(id: number) {
|
||||
const entity = await this.enrollmentRepo.findOne({ where: { id } });
|
||||
if (!entity) throw new NotFoundException('报名记录不存在');
|
||||
await this.enrollmentRepo.remove(entity);
|
||||
return { message: '已删除' };
|
||||
if (entity.status === 'archived') throw new BadRequestException('报名记录已归档');
|
||||
await this.enrollmentRepo.update(id, { status: 'archived' });
|
||||
return { message: '已归档' };
|
||||
}
|
||||
|
||||
private async assertEnrollmentBelongsToStudent(studentId: number, enrollmentId?: number) {
|
||||
@@ -158,8 +159,9 @@ export class ArchiveService {
|
||||
async deleteExamScore(id: number) {
|
||||
const entity = await this.examScoreRepo.findOne({ where: { id } });
|
||||
if (!entity) throw new NotFoundException('考试成绩不存在');
|
||||
await this.examScoreRepo.remove(entity);
|
||||
return { message: '已删除' };
|
||||
if (entity.status === 'archived') throw new BadRequestException('考试成绩已归档');
|
||||
await this.examScoreRepo.update(id, { status: 'archived' });
|
||||
return { message: '已归档' };
|
||||
}
|
||||
|
||||
async addLearningRecord(studentId: number, dto: CreateLearningRecordDto) {
|
||||
@@ -180,8 +182,9 @@ export class ArchiveService {
|
||||
async deleteLearningRecord(id: number) {
|
||||
const entity = await this.learningRecordRepo.findOne({ where: { id } });
|
||||
if (!entity) throw new NotFoundException('学习记录不存在');
|
||||
await this.learningRecordRepo.remove(entity);
|
||||
return { message: '已删除' };
|
||||
if (entity.status === 'archived') throw new BadRequestException('学习记录已归档');
|
||||
await this.learningRecordRepo.update(id, { status: 'archived' });
|
||||
return { message: '已归档' };
|
||||
}
|
||||
|
||||
async upsertResult(studentId: number, dto: UpsertResultDto) {
|
||||
@@ -241,13 +244,8 @@ export class ArchiveService {
|
||||
async deleteAttachment(id: number) {
|
||||
const entity = await this.attachmentRepo.findOne({ where: { id } });
|
||||
if (!entity) throw new NotFoundException('附件不存在');
|
||||
|
||||
const absPath = this.resolveAttachmentPath(entity.filePath);
|
||||
if (fs.existsSync(absPath)) {
|
||||
fs.unlinkSync(absPath);
|
||||
}
|
||||
|
||||
await this.attachmentRepo.remove(entity);
|
||||
return { message: '已删除' };
|
||||
if (entity.status === 'archived') throw new BadRequestException('附件已归档');
|
||||
await this.attachmentRepo.update(id, { status: 'archived' });
|
||||
return { message: '已归档' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ export class AttendanceDevicesController {
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '考勤机',
|
||||
action: '删除考勤机绑定',
|
||||
action: '停用考勤机绑定',
|
||||
targetId: id,
|
||||
targetType: 'attendanceDevice',
|
||||
ipAddress,
|
||||
|
||||
@@ -74,8 +74,11 @@ export class AttendanceDevicesService {
|
||||
async remove(id: number) {
|
||||
const device = await this.repo.findOne({ where: { id } });
|
||||
if (!device) throw new NotFoundException('考勤机不存在');
|
||||
await this.repo.delete(id);
|
||||
return { message: '已删除' };
|
||||
if (device.status === AttendanceDeviceStatus.DISABLED) {
|
||||
throw new BadRequestException('考勤机已停用');
|
||||
}
|
||||
await this.repo.update(id, { status: AttendanceDeviceStatus.DISABLED });
|
||||
return { message: '已停用(绑定数据已保留)' };
|
||||
}
|
||||
|
||||
async findActiveBySn(deviceSns: string[]) {
|
||||
|
||||
@@ -323,10 +323,10 @@ export class AttendanceController {
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '考勤管理',
|
||||
action: '删除考勤记录',
|
||||
action: '归档考勤记录',
|
||||
targetId: id,
|
||||
targetType: 'attendanceRecord',
|
||||
detail: `删除考勤记录 ${id}`,
|
||||
detail: `归档考勤记录 ${id}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ function createService(bills: Partial<Bill>[] = []) {
|
||||
find: jest.fn().mockResolvedValue(bills),
|
||||
findOne: jest.fn().mockResolvedValue(bills[0] ?? null),
|
||||
save: jest.fn(async (value) => value),
|
||||
update: jest.fn(),
|
||||
createQueryBuilder: jest.fn(() => queryBuilder()),
|
||||
};
|
||||
const manager = {
|
||||
@@ -55,23 +56,26 @@ describe('BillsService state and batch boundaries', () => {
|
||||
expect(billRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an empty batch delete', async () => {
|
||||
it('rejects an empty batch archive', async () => {
|
||||
const { service, dataSource } = createService();
|
||||
await expect(service.batchRemove([])).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a batch delete when some ids do not exist', async () => {
|
||||
it('rejects a batch archive when some ids do not exist', async () => {
|
||||
const { service, dataSource } = createService([{ id: 1, paidAmount: 0, status: 'unpaid' }]);
|
||||
await expect(service.batchRemove([1, 2])).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes a bill and its links in one transaction', async () => {
|
||||
const { service, dataSource, manager } = createService([{ id: 1, paidAmount: 0, status: 'unpaid' }]);
|
||||
await expect(service.remove(1)).resolves.toEqual({ message: '账单已删除' });
|
||||
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(manager.delete).toHaveBeenCalledTimes(2);
|
||||
expect(manager.update).toHaveBeenCalledTimes(1);
|
||||
it('archives an unpaid bill without deleting rows', async () => {
|
||||
const { service, billRepo, dataSource, manager } = createService([{ id: 1, paidAmount: 0, status: 'unpaid' }]);
|
||||
await expect(service.remove(1)).resolves.toEqual({ message: '账单已归档' });
|
||||
expect(billRepo.save).not.toHaveBeenCalled();
|
||||
expect(billRepo.createQueryBuilder).not.toHaveBeenCalled();
|
||||
expect(billRepo.findOne).toHaveBeenCalledWith({ where: { id: 1 } });
|
||||
expect((billRepo as any).update).toHaveBeenCalledWith(1, expect.objectContaining({ status: 'cancelled' }));
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
expect(manager.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -187,7 +187,7 @@ export class BillsController {
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '账单管理',
|
||||
action: '删除账单',
|
||||
action: '归档账单',
|
||||
targetId: id,
|
||||
targetType: 'bill',
|
||||
ipAddress,
|
||||
@@ -205,7 +205,7 @@ export class BillsController {
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '账单管理',
|
||||
action: '批量删除账单',
|
||||
action: '批量归档账单',
|
||||
detail: `IDs: ${body.ids.join(',')}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
|
||||
@@ -71,6 +71,7 @@ export class BillsService {
|
||||
periodStart,
|
||||
periodEnd,
|
||||
})
|
||||
.andWhere('e.status = :status', { status: 'active' })
|
||||
.getMany();
|
||||
|
||||
// 按宿舍分组费用
|
||||
@@ -177,6 +178,7 @@ export class BillsService {
|
||||
periodStart,
|
||||
periodEnd,
|
||||
})
|
||||
.andWhere('pe.status = :status', { status: 'active' })
|
||||
.andWhere('pe.billId IS NULL')
|
||||
.getMany();
|
||||
|
||||
@@ -392,31 +394,42 @@ export class BillsService {
|
||||
async remove(id: number) {
|
||||
const exists = await this.billRepo.findOne({ where: { id } });
|
||||
if (!exists) throw new NotFoundException('账单不存在');
|
||||
if (Number(exists.paidAmount) > 0 || exists.status === 'cancelled') {
|
||||
throw new BadRequestException('已发生资金流水的账单不能删除,请使用取消账单');
|
||||
if (exists.status === 'cancelled') throw new BadRequestException('账单已归档');
|
||||
if (Number(exists.paidAmount) > 0) {
|
||||
throw new BadRequestException('已发生资金流水的账单请使用取消账单并冲正');
|
||||
}
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.delete(BillItem, { billId: id });
|
||||
await manager.update(PersonalExpense, { billId: id }, { billId: null });
|
||||
await manager.delete(Bill, id);
|
||||
await this.billRepo.update(id, {
|
||||
status: 'cancelled',
|
||||
outstandingAmount: 0,
|
||||
cancelReason: '归档未支付账单',
|
||||
cancelledAt: new Date(),
|
||||
});
|
||||
return { message: '账单已删除' };
|
||||
return { message: '账单已归档' };
|
||||
}
|
||||
|
||||
async batchRemove(ids: number[]) {
|
||||
const uniqueIds = [...new Set(ids || [])];
|
||||
if (uniqueIds.length === 0) throw new BadRequestException('请选择要删除的账单');
|
||||
if (uniqueIds.length === 0) throw new BadRequestException('请选择要归档的账单');
|
||||
const bills = await this.billRepo.find({ where: { id: In(uniqueIds) } });
|
||||
if (bills.length !== uniqueIds.length) throw new NotFoundException('部分账单不存在');
|
||||
if (bills.some((bill) => Number(bill.paidAmount) > 0 || bill.status === 'cancelled')) {
|
||||
throw new BadRequestException('选中账单包含资金流水,不能批量删除');
|
||||
if (bills.some((bill) => Number(bill.paidAmount) > 0)) {
|
||||
throw new BadRequestException('选中账单包含资金流水,请逐条取消并冲正');
|
||||
}
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.delete(BillItem, { billId: In(uniqueIds) });
|
||||
await manager.update(PersonalExpense, { billId: In(uniqueIds) }, { billId: null });
|
||||
await manager.delete(Bill, uniqueIds);
|
||||
});
|
||||
return { message: `成功删除 ${uniqueIds.length} 条账单` };
|
||||
const targetIds = bills.filter((bill) => bill.status !== 'cancelled').map((bill) => bill.id);
|
||||
if (targetIds.length > 0) {
|
||||
await this.billRepo
|
||||
.createQueryBuilder()
|
||||
.update()
|
||||
.set({
|
||||
status: 'cancelled',
|
||||
outstandingAmount: 0,
|
||||
cancelReason: '批量归档未支付账单',
|
||||
cancelledAt: new Date(),
|
||||
})
|
||||
.where('id IN (:...ids)', { ids: targetIds })
|
||||
.execute();
|
||||
}
|
||||
return { message: `成功归档 ${targetIds.length} 条账单`, archived: targetIds.length };
|
||||
}
|
||||
|
||||
private assertStatusMatchesAmounts(bill: Bill, status: string) {
|
||||
|
||||
@@ -170,7 +170,7 @@ export class ClassesController {
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '班级管理',
|
||||
action: '删除班级',
|
||||
action: '归档班级',
|
||||
targetId: +id,
|
||||
targetType: 'class',
|
||||
ipAddress,
|
||||
|
||||
@@ -300,23 +300,9 @@ export class ClassesService {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/** 物理删除班级(已归档的才能删除) */
|
||||
/** 归档班级(兼容旧删除入口,不物理删除) */
|
||||
async remove(id: number) {
|
||||
const cls = await this.classRepo.findOne({ where: { id } });
|
||||
if (!cls) throw new NotFoundException('班级不存在');
|
||||
if (!cls.isArchived) throw new BadRequestException('请先归档再删除');
|
||||
|
||||
const sessionCount = await this.attendanceSessionRepo.count({
|
||||
where: { classId: id },
|
||||
});
|
||||
if (sessionCount > 0) {
|
||||
throw new ConflictException(
|
||||
`无法删除已产生 ${sessionCount} 个考勤场次的班级。请先取消或停用班级以保护历史考勤数据。`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.classRepo.remove(cls);
|
||||
return { success: true };
|
||||
return this.archive(id);
|
||||
}
|
||||
|
||||
async getStudents(classId: number) {
|
||||
|
||||
@@ -182,7 +182,7 @@ export class ClassroomRentalsController {
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '教室租赁',
|
||||
action: '删除租赁',
|
||||
action: '归档租赁',
|
||||
targetId: +id,
|
||||
targetType: 'classroom-rental',
|
||||
ipAddress,
|
||||
@@ -249,7 +249,7 @@ export class ClassroomRentalsController {
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '教室租赁',
|
||||
action: '删除合同',
|
||||
action: '移除合同',
|
||||
targetId: +id,
|
||||
targetType: 'classroom-rental',
|
||||
ipAddress,
|
||||
|
||||
@@ -326,7 +326,7 @@ export class ClassroomRentalsService {
|
||||
throw new BadRequestException('仅有效租赁可以取消');
|
||||
}
|
||||
await this.repo.update(id, { status: ClassroomRentalStatus.CANCELLED });
|
||||
await this.scheduleRepo.delete({ rentalId: id, scheduleType: 'RENTAL' });
|
||||
await this.scheduleRepo.update({ rentalId: id, scheduleType: 'RENTAL' }, { status: 'inactive' });
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
@@ -353,24 +353,12 @@ export class ClassroomRentalsService {
|
||||
|
||||
async remove(id: number) {
|
||||
const rental = await this.findOne(id);
|
||||
if (rental.effectiveStatus === ClassroomRentalStatus.ACTIVE) {
|
||||
throw new BadRequestException('进行中的租赁请先取消或结束');
|
||||
if (rental.status === ClassroomRentalStatus.CANCELLED) {
|
||||
return { message: '租赁订单已归档' };
|
||||
}
|
||||
// 同步删除对应排课记录
|
||||
await this.scheduleRepo.delete({ rentalId: id, scheduleType: 'RENTAL' });
|
||||
// 同时删除合同文件
|
||||
if (rental.contractPath) {
|
||||
const full = path.join(this.uploadDir, rental.contractPath);
|
||||
if (fs.existsSync(full)) {
|
||||
try {
|
||||
fs.unlinkSync(full);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.repo.delete(id);
|
||||
return { message: '删除成功' };
|
||||
await this.repo.update(id, { status: ClassroomRentalStatus.CANCELLED });
|
||||
await this.scheduleRepo.update({ rentalId: id, scheduleType: 'RENTAL' }, { status: 'inactive' });
|
||||
return { message: '租赁订单已归档(合同文件已保留)' };
|
||||
}
|
||||
|
||||
private withEffectiveStatus(rental: ClassroomRental) {
|
||||
@@ -442,7 +430,7 @@ export class ClassroomRentalsService {
|
||||
const fullPath = path.join(this.uploadDir, filename);
|
||||
// 路径遍历防护
|
||||
if (!fullPath.startsWith(this.uploadDir)) throw new BadRequestException('路径非法');
|
||||
// 删除旧文件
|
||||
// 替换旧文件
|
||||
if (rental.contractPath) {
|
||||
const oldPath = path.join(this.uploadDir, rental.contractPath);
|
||||
if (fs.existsSync(oldPath)) {
|
||||
@@ -473,7 +461,7 @@ export class ClassroomRentalsService {
|
||||
}
|
||||
}
|
||||
await this.repo.update(id, { contractPath: null as any, contractOriginalName: null as any });
|
||||
return { message: '合同已删除' };
|
||||
return { message: '合同已移除' };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -157,10 +157,10 @@ export class DepositsController {
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '押金管理',
|
||||
action: '删除分期',
|
||||
action: '归档分期',
|
||||
targetId: installmentId,
|
||||
targetType: 'deposit-installment',
|
||||
detail: `删除分期${installmentId}`,
|
||||
detail: `归档分期${installmentId}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
@@ -207,7 +207,7 @@ export class DepositsController {
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '押金管理',
|
||||
action: '删除押金记录',
|
||||
action: '归档押金记录',
|
||||
targetId: id,
|
||||
targetType: 'deposit',
|
||||
ipAddress,
|
||||
|
||||
@@ -36,12 +36,18 @@ export class DepositsService {
|
||||
.orderBy('d.createdAt', 'DESC');
|
||||
if (query?.studentId) qb.andWhere('d.studentId = :studentId', { studentId: query.studentId });
|
||||
if (query?.status) qb.andWhere('d.status = :status', { status: query.status });
|
||||
return qb.getMany();
|
||||
else qb.andWhere('d.status != :archived', { archived: 'archived' });
|
||||
const deposits = await qb.getMany();
|
||||
for (const deposit of deposits) {
|
||||
deposit.installments = deposit.installments?.filter((item) => item.status !== 'archived') ?? [];
|
||||
}
|
||||
return deposits;
|
||||
}
|
||||
|
||||
async findOne(id: number) {
|
||||
const deposit = await this.repo.findOne({ where: { id }, relations: ['student', 'installments'] });
|
||||
if (!deposit) throw new NotFoundException('押金记录不存在');
|
||||
deposit.installments = deposit.installments?.filter((item) => item.status !== 'archived') ?? [];
|
||||
return deposit;
|
||||
}
|
||||
|
||||
@@ -110,8 +116,9 @@ export class DepositsService {
|
||||
async deleteInstallment(id: number) {
|
||||
const installment = await this.installmentRepo.findOne({ where: { id } });
|
||||
if (!installment) throw new NotFoundException('分期记录不存在');
|
||||
await this.installmentRepo.delete(id);
|
||||
return { message: '删除成功' };
|
||||
if (installment.status === 'archived') throw new BadRequestException('分期记录已归档');
|
||||
await this.installmentRepo.update(id, { status: 'archived' });
|
||||
return { message: '已归档' };
|
||||
}
|
||||
|
||||
async refund(id: number, dto: RefundDepositDto, userId?: number) {
|
||||
@@ -137,8 +144,9 @@ export class DepositsService {
|
||||
async remove(id: number) {
|
||||
const deposit = await this.repo.findOne({ where: { id } });
|
||||
if (!deposit) throw new NotFoundException('押金记录不存在');
|
||||
await this.repo.delete(id);
|
||||
return { message: '删除成功' };
|
||||
if (deposit.status === 'archived') throw new BadRequestException('押金记录已归档');
|
||||
await this.repo.update(id, { status: 'archived' });
|
||||
return { message: '已归档' };
|
||||
}
|
||||
|
||||
async getStats() {
|
||||
|
||||
@@ -37,6 +37,9 @@ export class ArchiveAttachment {
|
||||
mimeType: string;
|
||||
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'active' })
|
||||
status: 'active' | 'archived';
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
|
||||
@@ -51,6 +51,9 @@ export class ExamScore {
|
||||
examDate: string;
|
||||
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'active' })
|
||||
status: 'active' | 'archived';
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@ export class LearningRecord {
|
||||
nextStep: string;
|
||||
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'active' })
|
||||
status: 'active' | 'archived';
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
|
||||
@@ -41,6 +41,9 @@ export class Occupancy {
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'active' })
|
||||
status: 'active' | 'archived';
|
||||
|
||||
@Column({ name: 'bed_id', type: 'integer', nullable: true })
|
||||
bedId: number;
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ export class PersonalExpense {
|
||||
@Column({ name: 'recorded_by', nullable: true })
|
||||
recordedBy: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'active' })
|
||||
status: 'active' | 'archived';
|
||||
|
||||
@Column({ name: 'bill_id', type: 'integer', nullable: true })
|
||||
billId: number | null;
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ export class RoomExpense {
|
||||
@Column({ name: 'recorded_by', nullable: true })
|
||||
recordedBy: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'active' })
|
||||
status: 'active' | 'archived';
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
|
||||
@@ -73,12 +73,12 @@ export class ExpenseTypesController {
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '费用类型',
|
||||
action: '删除费用类型',
|
||||
action: '停用费用类型',
|
||||
targetId: +id,
|
||||
targetType: 'expense_type',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return { message: '已删除' };
|
||||
return { message: '已停用' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ export class ExpenseTypesService {
|
||||
|
||||
async remove(id: number): Promise<void> {
|
||||
const t = await this.findOne(id);
|
||||
await this.repo.remove(t);
|
||||
if (!t.enabled) return;
|
||||
await this.repo.update(t.id, { enabled: false });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ export class ExpensesController {
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '费用管理',
|
||||
action: '删除费用',
|
||||
action: '归档费用',
|
||||
targetId: id,
|
||||
targetType: 'room_expense',
|
||||
ipAddress,
|
||||
@@ -172,7 +172,7 @@ export class ExpensesController {
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '费用管理',
|
||||
action: '批量删除宿舍费用',
|
||||
action: '批量归档宿舍费用',
|
||||
detail: `IDs: ${(body.ids || []).join(',')}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
@@ -235,7 +235,7 @@ export class ExpensesController {
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '费用管理',
|
||||
action: '删除费用',
|
||||
action: '归档费用',
|
||||
targetId: id,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
@@ -252,7 +252,7 @@ export class ExpensesController {
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '费用管理',
|
||||
action: '批量删除个人费用',
|
||||
action: '批量归档个人费用',
|
||||
detail: `IDs: ${(body.ids || []).join(',')}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
|
||||
@@ -76,6 +76,7 @@ export class ExpensesService {
|
||||
const qb = this.roomExpRepo
|
||||
.createQueryBuilder('e')
|
||||
.leftJoinAndSelect('e.room', 'room')
|
||||
.where('e.status = :status', { status: 'active' })
|
||||
.orderBy('e.createdAt', 'DESC');
|
||||
if (query?.roomId) qb.andWhere('e.roomId = :roomId', { roomId: query.roomId });
|
||||
if (query?.periodStart) qb.andWhere('e.periodStart >= :ps', { ps: query.periodStart });
|
||||
@@ -86,21 +87,23 @@ export class ExpensesService {
|
||||
async deleteRoomExpense(id: number) {
|
||||
const e = await this.roomExpRepo.findOne({ where: { id } });
|
||||
if (!e) throw new NotFoundException('费用记录不存在');
|
||||
await this.roomExpRepo.delete(id);
|
||||
return { message: '删除成功' };
|
||||
if (e.status === 'archived') throw new BadRequestException('费用记录已归档');
|
||||
await this.roomExpRepo.update(id, { status: 'archived' });
|
||||
return { message: '已归档' };
|
||||
}
|
||||
|
||||
async batchDeleteRoomExpenses(ids: number[]) {
|
||||
const uniqueIds = [...new Set(ids || [])];
|
||||
if (uniqueIds.length === 0) throw new BadRequestException('请选择要删除的记录');
|
||||
if (uniqueIds.length === 0) throw new BadRequestException('请选择要归档的记录');
|
||||
const existing = await this.roomExpRepo.find({ where: { id: In(uniqueIds) }, select: ['id'] });
|
||||
if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在');
|
||||
const result = await this.roomExpRepo
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.update()
|
||||
.set({ status: 'archived' })
|
||||
.where('id IN (:...ids)', { ids: uniqueIds })
|
||||
.execute();
|
||||
return { message: '批量删除成功', deleted: result.affected || 0 };
|
||||
return { message: `已批量归档 ${result.affected || 0} 条`, archived: result.affected || 0 };
|
||||
}
|
||||
|
||||
async updateRoomExpense(id: number, dto: Partial<CreateRoomExpenseDto>) {
|
||||
@@ -172,7 +175,7 @@ export class ExpensesService {
|
||||
}
|
||||
|
||||
async findPersonalExpenses(query?: { studentId?: number }) {
|
||||
const where: Record<string, unknown> = {};
|
||||
const where: Record<string, unknown> = { status: 'active' };
|
||||
if (query?.studentId) where.studentId = query.studentId;
|
||||
return this.personalExpRepo.find({
|
||||
where,
|
||||
@@ -184,14 +187,15 @@ export class ExpensesService {
|
||||
async deletePersonalExpense(id: number) {
|
||||
const e = await this.personalExpRepo.findOne({ where: { id } });
|
||||
if (!e) throw new NotFoundException('费用记录不存在');
|
||||
if (e.billId) throw new BadRequestException('已计入账单的个人费用不能删除,请先取消账单');
|
||||
await this.personalExpRepo.delete(id);
|
||||
return { message: '删除成功' };
|
||||
if (e.billId) throw new BadRequestException('已计入账单的个人费用不能归档,请先取消账单');
|
||||
if (e.status === 'archived') throw new BadRequestException('费用记录已归档');
|
||||
await this.personalExpRepo.update(id, { status: 'archived' });
|
||||
return { message: '已归档' };
|
||||
}
|
||||
|
||||
async batchDeletePersonalExpenses(ids: number[]) {
|
||||
const uniqueIds = [...new Set(ids || [])];
|
||||
if (uniqueIds.length === 0) throw new BadRequestException('请选择要删除的记录');
|
||||
if (uniqueIds.length === 0) throw new BadRequestException('请选择要归档的记录');
|
||||
const existing = await this.personalExpRepo.find({ where: { id: In(uniqueIds) } });
|
||||
if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在');
|
||||
if (existing.some((expense) => expense.billId)) {
|
||||
@@ -199,10 +203,11 @@ export class ExpensesService {
|
||||
}
|
||||
const result = await this.personalExpRepo
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.update()
|
||||
.set({ status: 'archived' })
|
||||
.where('id IN (:...ids)', { ids: uniqueIds })
|
||||
.execute();
|
||||
return { message: '批量删除成功', deleted: result.affected || 0 };
|
||||
return { message: `已批量归档 ${result.affected || 0} 条`, archived: result.affected || 0 };
|
||||
}
|
||||
|
||||
async updatePersonalExpense(id: number, dto: Partial<CreatePersonalExpenseDto>) {
|
||||
|
||||
@@ -166,7 +166,7 @@ export class OccupanciesController {
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '入住管理',
|
||||
action: '删除入住记录',
|
||||
action: '归档入住记录',
|
||||
targetId: +id,
|
||||
targetType: 'occupancy',
|
||||
ipAddress,
|
||||
@@ -184,7 +184,7 @@ export class OccupanciesController {
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '入住管理',
|
||||
action: '批量删除入住记录',
|
||||
action: '批量归档入住记录',
|
||||
detail: `IDs: ${(body.ids || []).join(',')}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
|
||||
@@ -39,6 +39,7 @@ export class OccupanciesService {
|
||||
.leftJoinAndSelect('o.room', 'room')
|
||||
.leftJoinAndSelect('o.bed', 'bed')
|
||||
.leftJoinAndSelect('o.locker', 'locker')
|
||||
.where('o.status = :status', { status: 'active' })
|
||||
.orderBy('o.checkInDate', 'DESC');
|
||||
if (query?.roomId) qb.andWhere('o.roomId = :roomId', { roomId: query.roomId });
|
||||
if (query?.studentId) qb.andWhere('o.studentId = :studentId', { studentId: query.studentId });
|
||||
@@ -287,13 +288,14 @@ export class OccupanciesService {
|
||||
async remove(id: number) {
|
||||
const occ = await this.repo.findOne({ where: { id } });
|
||||
if (!occ) throw new NotFoundException('入住记录不存在');
|
||||
if (!occ.checkOutDate) throw new BadRequestException('在住记录不能删除,请先办理退宿');
|
||||
await this.repo.delete(id);
|
||||
return { message: '删除成功' };
|
||||
if (!occ.checkOutDate) throw new BadRequestException('在住记录不能归档,请先办理退宿');
|
||||
if (occ.status === 'archived') throw new BadRequestException('入住记录已归档');
|
||||
await this.repo.update(id, { status: 'archived' });
|
||||
return { message: '已归档' };
|
||||
}
|
||||
|
||||
async batchRemove(ids: number[]) {
|
||||
if (!ids || ids.length === 0) throw new BadRequestException('请选择要删除的记录');
|
||||
if (!ids || ids.length === 0) throw new BadRequestException('请选择要归档的记录');
|
||||
const records = await this.repo.find({ where: { id: In(ids) }, relations: ['student'] });
|
||||
const skipped: string[] = [];
|
||||
const deletableIds: number[] = [];
|
||||
@@ -304,20 +306,21 @@ export class OccupanciesService {
|
||||
deletableIds.push(occ.id);
|
||||
}
|
||||
}
|
||||
let deleted = 0;
|
||||
let archived = 0;
|
||||
if (deletableIds.length > 0) {
|
||||
const result = await this.repo
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.update()
|
||||
.set({ status: 'archived' })
|
||||
.where('id IN (:...ids)', { ids: deletableIds })
|
||||
.execute();
|
||||
deleted = result.affected || 0;
|
||||
archived = result.affected || 0;
|
||||
}
|
||||
const message =
|
||||
skipped.length > 0
|
||||
? `成功删除 ${deleted} 条;${skipped.length} 条在住记录被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''}),请先办理退宿`
|
||||
: `批量删除成功,共 ${deleted} 条`;
|
||||
return { message, deleted, skipped: skipped.length };
|
||||
? `成功归档 ${archived} 条;${skipped.length} 条在住记录被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''}),请先办理退宿`
|
||||
: `批量归档成功,共 ${archived} 条`;
|
||||
return { message, archived, skipped: skipped.length };
|
||||
}
|
||||
|
||||
async batchCheckOut(dto: {
|
||||
|
||||
@@ -97,7 +97,7 @@ export class RbacController {
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: 'RBAC',
|
||||
action: '删除角色',
|
||||
action: '停用角色',
|
||||
targetId: +id,
|
||||
targetType: 'role',
|
||||
ipAddress,
|
||||
|
||||
@@ -24,39 +24,39 @@ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> =
|
||||
{ code: 'teacher:edit', name: '编辑教师', group: 'teacher' },
|
||||
{ code: 'student:create', name: '新增学生', group: 'student' },
|
||||
{ code: 'student:edit', name: '编辑学生', group: 'student' },
|
||||
{ code: 'student:delete', name: '删除学生', group: 'student' },
|
||||
{ code: 'student:delete', name: '归档学生', group: 'student' },
|
||||
{ code: 'student:import', name: '导入学生', group: 'student' },
|
||||
{ code: 'student:export', name: '导出学生', group: 'student' },
|
||||
{ code: 'room:view', name: '查看宿舍', group: 'room' },
|
||||
{ code: 'room:create', name: '新增宿舍', group: 'room' },
|
||||
{ code: 'room:edit', name: '编辑宿舍', group: 'room' },
|
||||
{ code: 'room:delete', name: '删除宿舍', group: 'room' },
|
||||
{ code: 'room:delete', name: '归档宿舍', group: 'room' },
|
||||
{ code: 'occupancy:view', name: '查看入住', group: 'occupancy' },
|
||||
{ code: 'occupancy:checkin', name: '办理入住', group: 'occupancy' },
|
||||
{ code: 'occupancy:checkout', name: '办理退宿', group: 'occupancy' },
|
||||
{ code: 'occupancy:transfer', name: '调换宿舍', group: 'occupancy' },
|
||||
{ code: 'occupancy:delete', name: '删除入住记录', group: 'occupancy' },
|
||||
{ code: 'occupancy:delete', name: '归档入住记录', group: 'occupancy' },
|
||||
{ code: 'expense:view', name: '查看费用', group: 'expense' },
|
||||
{ code: 'expense:create', name: '录入费用', group: 'expense' },
|
||||
{ code: 'expense:edit', name: '编辑费用', group: 'expense' },
|
||||
{ code: 'expense:delete', name: '删除费用', group: 'expense' },
|
||||
{ code: 'expense:delete', name: '归档费用', group: 'expense' },
|
||||
{ code: 'bill:view', name: '查看账单', group: 'bill' },
|
||||
{ code: 'bill:generate', name: '生成账单', group: 'bill' },
|
||||
{ code: 'bill:confirm', name: '确认账单', group: 'bill' },
|
||||
{ code: 'bill:delete', name: '删除账单', group: 'bill' },
|
||||
{ code: 'bill:delete', name: '归档账单', group: 'bill' },
|
||||
{ code: 'bill:export-excel', name: '导出 Excel', group: 'bill' },
|
||||
{ code: 'bill:export-pdf', name: '导出 PDF', group: 'bill' },
|
||||
{ code: 'deposit:view', name: '查看押金', group: 'deposit' },
|
||||
{ code: 'deposit:create', name: '新增押金', group: 'deposit' },
|
||||
{ code: 'deposit:edit', name: '编辑押金', group: 'deposit' },
|
||||
{ code: 'deposit:delete', name: '删除押金', group: 'deposit' },
|
||||
{ code: 'deposit:delete', name: '归档押金', group: 'deposit' },
|
||||
{ code: 'deposit:refund', name: '直接退还押金', group: 'deposit' },
|
||||
{ code: 'wallet:view', name: '查看学生余额', group: 'wallet' },
|
||||
{ code: 'wallet:edit', name: '充值和调账', group: 'wallet' },
|
||||
{ code: 'classroom:view', name: '查看教室', group: 'classroom' },
|
||||
{ code: 'classroom:create', name: '新增教室', group: 'classroom' },
|
||||
{ code: 'classroom:edit', name: '编辑教室', group: 'classroom' },
|
||||
{ code: 'classroom:delete', name: '删除教室', group: 'classroom' },
|
||||
{ code: 'classroom:delete', name: '归档教室', group: 'classroom' },
|
||||
{ code: 'organization:view', name: '查看机构', group: 'organization' },
|
||||
{ code: 'organization:create', name: '新增机构', group: 'organization' },
|
||||
{ code: 'organization:edit', name: '编辑机构', group: 'organization' },
|
||||
@@ -64,7 +64,7 @@ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> =
|
||||
{ code: 'rental:view', name: '查看租赁订单', group: 'rental' },
|
||||
{ code: 'rental:create', name: '新增租赁订单', group: 'rental' },
|
||||
{ code: 'rental:edit', name: '编辑租赁订单', group: 'rental' },
|
||||
{ code: 'rental:delete', name: '删除租赁订单', group: 'rental' },
|
||||
{ code: 'rental:delete', name: '归档租赁订单', group: 'rental' },
|
||||
{ code: 'log:view', name: '查看操作日志', group: 'log' },
|
||||
{ code: 'log:create', name: '写入操作日志', group: 'log' },
|
||||
{ code: 'user:view', name: '查看用户', group: 'user' },
|
||||
@@ -74,15 +74,15 @@ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> =
|
||||
{ code: 'role:view', name: '查看角色', group: 'role' },
|
||||
{ code: 'role:create', name: '创建角色', group: 'role' },
|
||||
{ code: 'role:edit', name: '编辑角色', group: 'role' },
|
||||
{ code: 'role:delete', name: '删除角色', group: 'role' },
|
||||
{ code: 'role:delete', name: '停用角色', group: 'role' },
|
||||
{ code: 'class:view', name: '查看班级', group: 'class' },
|
||||
{ code: 'class:create', name: '创建班级', group: 'class' },
|
||||
{ code: 'class:edit', name: '编辑班级', group: 'class' },
|
||||
{ code: 'class:delete', name: '删除班级', group: 'class' },
|
||||
{ code: 'class:delete', name: '归档班级', group: 'class' },
|
||||
{ code: 'schedule:view', name: '查看排课', group: 'schedule' },
|
||||
{ code: 'schedule:create', name: '创建排课', group: 'schedule' },
|
||||
{ code: 'schedule:edit', name: '编辑排课', group: 'schedule' },
|
||||
{ code: 'schedule:delete', name: '删除排课', group: 'schedule' },
|
||||
{ code: 'schedule:delete', name: '停用排课', group: 'schedule' },
|
||||
{ code: 'attendance:view', name: '查看考勤', group: 'attendance' },
|
||||
{ code: 'attendance:create', name: '新增考勤', group: 'attendance' },
|
||||
{ code: 'attendance:edit', name: '编辑全部考勤', group: 'attendance' },
|
||||
@@ -442,9 +442,11 @@ export class RbacService {
|
||||
|
||||
async deleteRole(id: number): Promise<{ message: string }> {
|
||||
const role = await this.roleRepo.findOneOrFail({ where: { id } });
|
||||
if (role.isSystem) throw new Error('系统角色不可删除');
|
||||
await this.roleRepo.remove(role);
|
||||
return { message: '角色已删除' };
|
||||
if (role.isSystem) throw new Error('系统角色不可停用');
|
||||
if (role.status === 0) return { message: '角色已停用' };
|
||||
role.status = 0;
|
||||
await this.roleRepo.save(role);
|
||||
return { message: '角色已停用' };
|
||||
}
|
||||
|
||||
async findAllPermissions(): Promise<Permission[]> {
|
||||
|
||||
@@ -395,7 +395,7 @@ export class RoomsService {
|
||||
async getRoomBeds(roomId: number): Promise<Bed[]> {
|
||||
const room = await this.repo.findOne({ where: { id: roomId } });
|
||||
if (!room) throw new NotFoundException('宿舍不存在');
|
||||
return this.bedRepo.find({ where: { roomId }, order: { bedNumber: 'ASC' } });
|
||||
return this.bedRepo.find({ where: { roomId, status: Not('archived') }, order: { bedNumber: 'ASC' } });
|
||||
}
|
||||
|
||||
async getRoomAvailableBeds(roomId: number): Promise<Bed[]> {
|
||||
@@ -437,15 +437,16 @@ export class RoomsService {
|
||||
async deleteBed(roomId: number, id: number): Promise<void> {
|
||||
const bed = await this.bedRepo.findOne({ where: { id, roomId } });
|
||||
if (!bed) throw new NotFoundException('床位不存在');
|
||||
if (bed.status === 'occupied') throw new BadRequestException('该床位有人入住,无法删除');
|
||||
await this.bedRepo.remove(bed);
|
||||
if (bed.status === 'occupied') throw new BadRequestException('该床位有人入住,无法归档');
|
||||
if (bed.status === 'archived') throw new BadRequestException('该床位已归档');
|
||||
await this.bedRepo.update(id, { status: 'archived' });
|
||||
}
|
||||
|
||||
async batchCreateBeds(roomId: number, dto: BatchCreateBedDto): Promise<Bed[]> {
|
||||
const room = await this.repo.findOne({ where: { id: roomId } });
|
||||
if (!room) throw new NotFoundException('宿舍不存在');
|
||||
if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加床位');
|
||||
const existing = await this.bedRepo.find({ where: { roomId }, order: { bedNumber: 'ASC' } });
|
||||
const existing = await this.bedRepo.find({ where: { roomId, status: Not('archived') }, order: { bedNumber: 'ASC' } });
|
||||
this.assertCanAddBedsFromCount(room, existing.length, dto.count);
|
||||
const numbers = existing.map((b) => {
|
||||
const match = b.bedNumber.match(/^\d+/);
|
||||
@@ -495,7 +496,7 @@ export class RoomsService {
|
||||
async getRoomLockers(roomId: number): Promise<Locker[]> {
|
||||
const room = await this.repo.findOne({ where: { id: roomId } });
|
||||
if (!room) throw new NotFoundException('宿舍不存在');
|
||||
return this.lockerRepo.find({ where: { roomId }, order: { lockerNumber: 'ASC' } });
|
||||
return this.lockerRepo.find({ where: { roomId, status: Not('archived') }, order: { lockerNumber: 'ASC' } });
|
||||
}
|
||||
|
||||
async getRoomAvailableLockers(roomId: number): Promise<Locker[]> {
|
||||
@@ -538,8 +539,9 @@ export class RoomsService {
|
||||
async deleteLocker(roomId: number, id: number): Promise<void> {
|
||||
const locker = await this.lockerRepo.findOne({ where: { id, roomId } });
|
||||
if (!locker) throw new NotFoundException('柜子不存在');
|
||||
if (locker.status === 'occupied') throw new BadRequestException('该柜子有人占用,无法删除');
|
||||
await this.lockerRepo.remove(locker);
|
||||
if (locker.status === 'occupied') throw new BadRequestException('该柜子有人占用,无法归档');
|
||||
if (locker.status === 'archived') throw new BadRequestException('该柜子已归档');
|
||||
await this.lockerRepo.update(id, { status: 'archived' });
|
||||
}
|
||||
|
||||
async batchCreateLockers(roomId: number, dto: BatchCreateLockerDto): Promise<Locker[]> {
|
||||
@@ -547,7 +549,7 @@ export class RoomsService {
|
||||
if (!room) throw new NotFoundException('宿舍不存在');
|
||||
if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加柜子');
|
||||
const existing = await this.lockerRepo.find({
|
||||
where: { roomId },
|
||||
where: { roomId, status: Not('archived') },
|
||||
order: { lockerNumber: 'ASC' },
|
||||
});
|
||||
const numbers = existing.map((b) => {
|
||||
|
||||
@@ -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'],
|
||||
});
|
||||
|
||||
@@ -333,7 +333,7 @@ export class StudentsController {
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '学生管理',
|
||||
action: '删除学生',
|
||||
action: '归档学生',
|
||||
targetId: id,
|
||||
targetType: 'student',
|
||||
ipAddress,
|
||||
@@ -351,7 +351,7 @@ export class StudentsController {
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '学生管理',
|
||||
action: '批量删除学生',
|
||||
action: '批量归档学生',
|
||||
detail: `IDs: ${(body.ids || []).join(',')}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
|
||||
Reference in New Issue
Block a user