refactor: resolve remaining field audit issues
This commit is contained in:
@@ -137,6 +137,42 @@ export class ClassroomRentalsController {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Put(':id/cancel')
|
||||
@RequirePermission('rental:edit')
|
||||
async cancel(@Param('id') id: string, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.cancel(+id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '教室租赁',
|
||||
action: '取消租赁',
|
||||
targetId: +id,
|
||||
targetType: 'classroom-rental',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Put(':id/end')
|
||||
@RequirePermission('rental:edit')
|
||||
async end(@Param('id') id: string, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.end(+id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '教室租赁',
|
||||
action: '结束租赁',
|
||||
targetId: +id,
|
||||
targetType: 'classroom-rental',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('rental:delete')
|
||||
async remove(@Param('id') id: string, @Request() req: any) {
|
||||
|
||||
@@ -243,7 +243,7 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
startDate: '2026-03-01',
|
||||
endDate: '2026-03-31',
|
||||
};
|
||||
const classroom = { id: 1, departmentId: 10 } as Classroom;
|
||||
const classroom = { id: 1, status: 'available' } as Classroom;
|
||||
const hostOrganization = {
|
||||
id: 1,
|
||||
name: 'Host',
|
||||
@@ -315,8 +315,8 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
id: 1,
|
||||
classroomId: 1,
|
||||
lesseeOrganizationId: 2,
|
||||
startDate: '2026-03-01',
|
||||
endDate: '2026-03-31',
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2099-03-31',
|
||||
status: 'active',
|
||||
notes: '',
|
||||
lesseeOrganization: { id: 2, name: 'Organization A' } as Organization,
|
||||
@@ -324,8 +324,8 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
} as ClassroomRental;
|
||||
const updatedRental = {
|
||||
...existingRental,
|
||||
startDate: '2026-04-01',
|
||||
endDate: '2026-04-30',
|
||||
startDate: '2026-08-01',
|
||||
endDate: '2099-04-30',
|
||||
};
|
||||
const existingSchedule = {
|
||||
id: 50,
|
||||
@@ -339,12 +339,12 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
scheduleRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder<ClassSchedule>([]));
|
||||
scheduleRepo.findOne.mockResolvedValue(existingSchedule);
|
||||
|
||||
const dto: UpdateRentalDto = { startDate: '2026-04-01', endDate: '2026-04-30' };
|
||||
const dto: UpdateRentalDto = { startDate: '2026-08-01', endDate: '2099-04-30' };
|
||||
await service.update(1, dto);
|
||||
|
||||
expect(rentalRepo.update).toHaveBeenCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ startDate: '2026-04-01', endDate: '2026-04-30' }),
|
||||
expect.objectContaining({ startDate: '2026-08-01', endDate: '2099-04-30' }),
|
||||
);
|
||||
expect(scheduleRepo.update).toHaveBeenCalledWith(
|
||||
50,
|
||||
@@ -352,8 +352,8 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
scheduleType: 'RENTAL',
|
||||
rentalId: 1,
|
||||
classroomId: 1,
|
||||
startDate: '2026-04-01',
|
||||
endDate: '2026-04-30',
|
||||
startDate: '2026-08-01',
|
||||
endDate: '2099-04-30',
|
||||
status: 'active',
|
||||
subject: 'Organization A 租赁',
|
||||
}),
|
||||
@@ -362,27 +362,59 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
expect(scheduleRepo.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes the RENTAL schedule row when status changes to cancelled', async () => {
|
||||
it('deletes the RENTAL schedule row when the rental is cancelled', async () => {
|
||||
const rental = {
|
||||
id: 1,
|
||||
classroomId: 1,
|
||||
lesseeOrganizationId: 2,
|
||||
startDate: '2026-03-01',
|
||||
endDate: '2026-03-31',
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2099-03-31',
|
||||
status: 'active',
|
||||
lesseeOrganization: { id: 2, name: 'Organization A' } as Organization,
|
||||
} as ClassroomRental;
|
||||
const cancelledRental = { ...rental, status: 'cancelled' };
|
||||
const cancelledRental = { ...rental, status: 'cancelled' } as ClassroomRental;
|
||||
|
||||
rentalRepo.findOne.mockResolvedValueOnce(rental).mockResolvedValueOnce(cancelledRental);
|
||||
|
||||
await service.update(1, { status: 'cancelled' });
|
||||
await service.cancel(1);
|
||||
|
||||
expect(rentalRepo.update).toHaveBeenCalledWith(1, { status: 'cancelled' });
|
||||
expect(scheduleRepo.delete).toHaveBeenCalledWith({ rentalId: 1, scheduleType: 'RENTAL' });
|
||||
expect(scheduleRepo.findOne).not.toHaveBeenCalled();
|
||||
expect(scheduleRepo.update).not.toHaveBeenCalled();
|
||||
expect(scheduleRepo.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('lifecycle actions', () => {
|
||||
it('ends an active rental and shortens a future end date', async () => {
|
||||
const rental = {
|
||||
id: 1,
|
||||
classroomId: 1,
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2099-12-31',
|
||||
status: 'active',
|
||||
lesseeOrganization: { name: 'Organization A' },
|
||||
} as ClassroomRental;
|
||||
const ended = { ...rental, status: 'ended', endDate: '2026-07-13' } as ClassroomRental;
|
||||
rentalRepo.findOne.mockResolvedValueOnce(rental).mockResolvedValueOnce(ended);
|
||||
scheduleRepo.findOne.mockResolvedValue({ id: 50 } as ClassSchedule);
|
||||
|
||||
await service.end(1);
|
||||
|
||||
expect(rentalRepo.update).toHaveBeenCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ status: 'ended' }),
|
||||
);
|
||||
expect(scheduleRepo.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects ending a future rental', async () => {
|
||||
rentalRepo.findOne.mockResolvedValue({
|
||||
id: 1,
|
||||
startDate: '2099-01-01',
|
||||
endDate: '2099-12-31',
|
||||
status: 'active',
|
||||
} as ClassroomRental);
|
||||
|
||||
await expect(service.end(1)).rejects.toThrow('租赁尚未开始');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -394,7 +426,7 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
lesseeOrganizationId: 2,
|
||||
startDate: '2026-03-01',
|
||||
endDate: '2026-03-31',
|
||||
status: 'active',
|
||||
status: 'cancelled',
|
||||
lesseeOrganization: { id: 2, name: 'Organization A' } as Organization,
|
||||
} as ClassroomRental;
|
||||
|
||||
@@ -419,7 +451,7 @@ describe('ClassroomRentalsService — organization roles', () => {
|
||||
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder<ClassroomRental>([])),
|
||||
} as any;
|
||||
const classroomRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({ id: 1, departmentId: 10 }),
|
||||
findOne: jest.fn().mockResolvedValue({ id: 1, status: 'available' }),
|
||||
} as any;
|
||||
const organizationRepo = {
|
||||
findOne: jest
|
||||
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Not, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
||||
import { ClassroomRental } from '../entities/classroom-rental.entity';
|
||||
import { Classroom } from '../entities/classroom.entity';
|
||||
import { ClassroomRental, ClassroomRentalStatus } from '../entities/classroom-rental.entity';
|
||||
import { Classroom, ClassroomStatus } from '../entities/classroom.entity';
|
||||
import { Organization } from '../entities/organization.entity';
|
||||
import { ClassSchedule } from '../entities/class-schedule.entity';
|
||||
import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto';
|
||||
@@ -70,8 +70,11 @@ export class ClassroomRentalsService {
|
||||
const last = `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
||||
qb.andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last });
|
||||
}
|
||||
if (!query?.includeEnded) qb.andWhere('r.status != :cancelled', { cancelled: 'cancelled' });
|
||||
return qb.getMany();
|
||||
if (!query?.includeEnded) {
|
||||
qb.andWhere('r.status = :active', { active: ClassroomRentalStatus.ACTIVE });
|
||||
}
|
||||
const rentals = await qb.getMany();
|
||||
return rentals.map((rental) => this.withEffectiveStatus(rental));
|
||||
}
|
||||
|
||||
async findOne(id: number) {
|
||||
@@ -80,7 +83,7 @@ export class ClassroomRentalsService {
|
||||
relations: ['classroom', 'lessorOrganization', 'lesseeOrganization'],
|
||||
});
|
||||
if (!rental) throw new NotFoundException('租赁订单不存在');
|
||||
return rental;
|
||||
return this.withEffectiveStatus(rental);
|
||||
}
|
||||
|
||||
async getUnavailableDates(classroomId: number, year: number, month: number, excludeId?: number) {
|
||||
@@ -93,7 +96,7 @@ export class ClassroomRentalsService {
|
||||
where: {
|
||||
...(excludeId ? { id: Not(excludeId) } : {}),
|
||||
classroomId,
|
||||
status: Not('cancelled'),
|
||||
status: ClassroomRentalStatus.ACTIVE,
|
||||
startDate: LessThanOrEqual(monthEnd),
|
||||
endDate: MoreThanOrEqual(monthStart),
|
||||
},
|
||||
@@ -101,7 +104,7 @@ export class ClassroomRentalsService {
|
||||
this.scheduleRepo.find({
|
||||
where: {
|
||||
classroomId,
|
||||
status: 'active',
|
||||
status: ClassroomRentalStatus.ACTIVE,
|
||||
scheduleType: 'INTERNAL',
|
||||
startDate: LessThanOrEqual(monthEnd),
|
||||
endDate: MoreThanOrEqual(monthStart),
|
||||
@@ -133,7 +136,7 @@ export class ClassroomRentalsService {
|
||||
.createQueryBuilder('r')
|
||||
.leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization')
|
||||
.where('r.classroomId = :cid', { cid: classroomId })
|
||||
.andWhere('r.status != :cancelled', { cancelled: 'cancelled' })
|
||||
.andWhere('r.status = :active', { active: ClassroomRentalStatus.ACTIVE })
|
||||
.andWhere('r.startDate <= :end', { end: endDate })
|
||||
.andWhere('r.endDate >= :start', { start: startDate });
|
||||
if (excludeId) qb.andWhere('r.id != :excludeId', { excludeId });
|
||||
@@ -222,6 +225,9 @@ export class ClassroomRentalsService {
|
||||
if (dto.startDate > dto.endDate) throw new BadRequestException('起始日期不能晚于结束日期');
|
||||
const classroom = await this.classroomRepo.findOne({ where: { id: dto.classroomId } });
|
||||
if (!classroom) throw new NotFoundException('教室不存在');
|
||||
if (classroom.status !== ClassroomStatus.AVAILABLE) {
|
||||
throw new BadRequestException('仅可用教室可以创建租赁');
|
||||
}
|
||||
const lessorOrganization = dto.lessorOrganizationId
|
||||
? await this.organizationRepo.findOne({
|
||||
where: { id: dto.lessorOrganizationId, status: 'active' },
|
||||
@@ -253,7 +259,7 @@ export class ClassroomRentalsService {
|
||||
lessorOrganizationId: lessorOrganization.id,
|
||||
lesseeOrganizationId: lesseeOrganization.id,
|
||||
createdBy: userId,
|
||||
status: 'active',
|
||||
status: ClassroomRentalStatus.ACTIVE,
|
||||
});
|
||||
const saved = await this.repo.save(rental);
|
||||
await this.syncScheduleFromRental(saved, lesseeOrganization.name);
|
||||
@@ -262,11 +268,21 @@ export class ClassroomRentalsService {
|
||||
|
||||
async update(id: number, dto: UpdateRentalDto) {
|
||||
const rental = await this.findOne(id);
|
||||
if (rental.effectiveStatus !== ClassroomRentalStatus.ACTIVE) {
|
||||
throw new BadRequestException('已结束或已取消的租赁不能编辑');
|
||||
}
|
||||
// 若修改了教室/日期,重新冲突检查
|
||||
const newClassroomId = dto.classroomId ?? rental.classroomId;
|
||||
const newStart = dto.startDate ?? rental.startDate;
|
||||
const newEnd = dto.endDate ?? rental.endDate;
|
||||
if (newStart > newEnd) throw new BadRequestException('起始日期不能晚于结束日期');
|
||||
if (dto.classroomId && dto.classroomId !== rental.classroomId) {
|
||||
const classroom = await this.classroomRepo.findOne({ where: { id: dto.classroomId } });
|
||||
if (!classroom) throw new NotFoundException('教室不存在');
|
||||
if (classroom.status !== ClassroomStatus.AVAILABLE) {
|
||||
throw new BadRequestException('仅可用教室可以承接租赁');
|
||||
}
|
||||
}
|
||||
if (dto.classroomId || dto.startDate || dto.endDate) {
|
||||
const conflicts = await this.findConflicts(newClassroomId, newStart, newEnd, id);
|
||||
if (conflicts.length > 0) {
|
||||
@@ -300,16 +316,46 @@ export class ClassroomRentalsService {
|
||||
}
|
||||
await this.repo.update(id, dto);
|
||||
const updated = await this.findOne(id);
|
||||
if (dto.status === 'cancelled') {
|
||||
await this.scheduleRepo.delete({ rentalId: id, scheduleType: 'RENTAL' });
|
||||
} else {
|
||||
await this.syncScheduleFromRental(updated);
|
||||
}
|
||||
await this.syncScheduleFromRental(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async cancel(id: number) {
|
||||
const rental = await this.findOne(id);
|
||||
if (rental.effectiveStatus !== ClassroomRentalStatus.ACTIVE) {
|
||||
throw new BadRequestException('仅有效租赁可以取消');
|
||||
}
|
||||
await this.repo.update(id, { status: ClassroomRentalStatus.CANCELLED });
|
||||
await this.scheduleRepo.delete({ rentalId: id, scheduleType: 'RENTAL' });
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
async end(id: number) {
|
||||
const rental = await this.findOne(id);
|
||||
if (rental.effectiveStatus !== ClassroomRentalStatus.ACTIVE) {
|
||||
throw new BadRequestException('仅有效租赁可以结束');
|
||||
}
|
||||
const today = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(new Date());
|
||||
if (rental.startDate > today) throw new BadRequestException('租赁尚未开始,不能结束');
|
||||
await this.repo.update(id, {
|
||||
status: ClassroomRentalStatus.ENDED,
|
||||
endDate: rental.endDate > today ? today : rental.endDate,
|
||||
});
|
||||
const ended = await this.findOne(id);
|
||||
await this.syncScheduleFromRental(ended);
|
||||
return ended;
|
||||
}
|
||||
|
||||
async remove(id: number) {
|
||||
const rental = await this.findOne(id);
|
||||
if (rental.effectiveStatus === ClassroomRentalStatus.ACTIVE) {
|
||||
throw new BadRequestException('进行中的租赁请先取消或结束');
|
||||
}
|
||||
// 同步删除对应排课记录
|
||||
await this.scheduleRepo.delete({ rentalId: id, scheduleType: 'RENTAL' });
|
||||
// 同时删除合同文件
|
||||
@@ -327,6 +373,20 @@ export class ClassroomRentalsService {
|
||||
return { message: '删除成功' };
|
||||
}
|
||||
|
||||
private withEffectiveStatus(rental: ClassroomRental) {
|
||||
const today = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(new Date());
|
||||
const effectiveStatus =
|
||||
rental.status === ClassroomRentalStatus.ACTIVE && rental.endDate < today
|
||||
? ClassroomRentalStatus.ENDED
|
||||
: rental.status;
|
||||
return Object.assign(rental, { effectiveStatus });
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步租赁订单到 class_schedules(schedule_type = 'RENTAL')
|
||||
*/
|
||||
@@ -348,7 +408,7 @@ export class ClassroomRentalsService {
|
||||
teacherId: null,
|
||||
scheduleType: 'RENTAL',
|
||||
rentalId: rental.id,
|
||||
status: 'active',
|
||||
status: rental.status === ClassroomRentalStatus.CANCELLED ? 'cancelled' : 'active',
|
||||
notes: rental.notes,
|
||||
};
|
||||
if (schedule) {
|
||||
@@ -437,14 +497,16 @@ export class ClassroomRentalsService {
|
||||
const last = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
const classrooms = await this.classroomRepo.find({
|
||||
where: { status: Not('archived') },
|
||||
where: { status: Not(ClassroomStatus.ARCHIVED) },
|
||||
order: { building: 'ASC', name: 'ASC' },
|
||||
});
|
||||
const rentals = await this.repo
|
||||
.createQueryBuilder('r')
|
||||
.leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization')
|
||||
.leftJoinAndSelect('r.classroom', 'classroom')
|
||||
.where('r.status != :cancelled', { cancelled: 'cancelled' })
|
||||
.where('r.status IN (:...statuses)', {
|
||||
statuses: [ClassroomRentalStatus.ACTIVE, ClassroomRentalStatus.ENDED],
|
||||
})
|
||||
.andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last })
|
||||
.getMany();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsOptional, IsString, IsInt, IsNumber, IsEnum, IsDateString } from 'class-validator';
|
||||
import { IsOptional, IsString, IsInt, IsNumber, IsDateString } from 'class-validator';
|
||||
|
||||
export class CreateRentalDto {
|
||||
@IsInt()
|
||||
@@ -62,8 +62,4 @@ export class UpdateRentalDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['active', 'ended', 'cancelled'])
|
||||
status?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user