forked from wangziqi/gongxue-base
refactor: resolve remaining field audit issues
This commit is contained in:
@@ -1,14 +1,13 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Not } from 'typeorm';
|
||||
import { Classroom } from '../entities/classroom.entity';
|
||||
import { ClassroomRental } from '../entities/classroom-rental.entity';
|
||||
import { Repository, Not, MoreThanOrEqual } from 'typeorm';
|
||||
import { Classroom, ClassroomStatus } from '../entities/classroom.entity';
|
||||
import { ClassroomRental, ClassroomRentalStatus } from '../entities/classroom-rental.entity';
|
||||
import { ClassSchedule } from '../entities/class-schedule.entity';
|
||||
import { CreateClassroomDto, UpdateClassroomDto } from './dto/classroom.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ClassroomsService {
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Classroom) private repo: Repository<Classroom>,
|
||||
@InjectRepository(ClassroomRental) private rentalRepo: Repository<ClassroomRental>,
|
||||
@@ -21,49 +20,148 @@ export class ClassroomsService {
|
||||
if (query?.roomType) where.roomType = query.roomType;
|
||||
if (!query?.includeArchived) where.status = Not('archived');
|
||||
const list = await this.repo.find({ where, order: { building: 'ASC', name: 'ASC' } });
|
||||
const usageMap = await this.getCurrentUsageForClassrooms(list.map((c) => c.id));
|
||||
return list.map((c) => ({ ...c, currentUsage: usageMap.get(c.id) ?? null }));
|
||||
const usageMap = await this.getUsageForClassrooms(list.map((c) => c.id));
|
||||
return list.map((classroom) => this.withEffectiveStatus(classroom, usageMap.get(classroom.id)));
|
||||
}
|
||||
|
||||
async findOne(id: number) {
|
||||
const cls = await this.repo.findOne({ where: { id } });
|
||||
if (!cls) throw new NotFoundException('教室不存在');
|
||||
const usageMap = await this.getCurrentUsageForClassrooms([id]);
|
||||
return { ...cls, currentUsage: usageMap.get(id) ?? null };
|
||||
const usageMap = await this.getUsageForClassrooms([id]);
|
||||
return this.withEffectiveStatus(cls, usageMap.get(id));
|
||||
}
|
||||
|
||||
async create(dto: CreateClassroomDto) {
|
||||
const exists = await this.repo.findOne({ where: { name: dto.name } });
|
||||
if (exists) throw new BadRequestException(`教室 ${dto.name} 已存在`);
|
||||
return this.repo.save(this.repo.create(dto));
|
||||
return this.repo.save(this.repo.create({ ...dto, status: ClassroomStatus.AVAILABLE }));
|
||||
}
|
||||
|
||||
async update(id: number, dto: UpdateClassroomDto) {
|
||||
await this.findOne(id);
|
||||
const classroom = await this.repo.findOne({ where: { id } });
|
||||
if (!classroom) throw new NotFoundException('教室不存在');
|
||||
if (dto.status === ClassroomStatus.MAINTENANCE && classroom.status !== dto.status) {
|
||||
await this.assertNoActiveAllocations(id);
|
||||
}
|
||||
await this.repo.update(id, dto);
|
||||
return this.repo.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async remove(id: number) {
|
||||
await this.findOne(id);
|
||||
await this.repo.update(id, { status: 'archived' });
|
||||
const classroom = await this.repo.findOne({ where: { id } });
|
||||
if (!classroom) throw new NotFoundException('教室不存在');
|
||||
await this.assertNoActiveAllocations(id);
|
||||
await this.repo.update(id, { status: ClassroomStatus.ARCHIVED });
|
||||
return { message: '已归档' };
|
||||
}
|
||||
|
||||
async restore(id: number) {
|
||||
await this.findOne(id);
|
||||
await this.repo.update(id, { status: 'reserved' });
|
||||
const classroom = await this.repo.findOne({ where: { id } });
|
||||
if (!classroom) throw new NotFoundException('教室不存在');
|
||||
await this.repo.update(id, { status: ClassroomStatus.AVAILABLE });
|
||||
return this.repo.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
private async getCurrentUsageForClassrooms(classroomIds: number[]): Promise<Map<number, { type: 'schedule' | 'rental'; title: string; startTime: string; endTime: string }>> {
|
||||
const result = new Map<number, { type: 'schedule' | 'rental'; title: string; startTime: string; endTime: string }>();
|
||||
private withEffectiveStatus(
|
||||
classroom: Classroom,
|
||||
usage?: {
|
||||
state: 'in_use' | 'reserved';
|
||||
currentUsage: {
|
||||
type: 'schedule' | 'rental';
|
||||
title: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
} | null;
|
||||
},
|
||||
) {
|
||||
const effectiveStatus =
|
||||
classroom.status === ClassroomStatus.ARCHIVED ||
|
||||
classroom.status === ClassroomStatus.MAINTENANCE
|
||||
? classroom.status
|
||||
: (usage?.state ?? ClassroomStatus.AVAILABLE);
|
||||
return { ...classroom, currentUsage: usage?.currentUsage ?? null, effectiveStatus };
|
||||
}
|
||||
|
||||
private async assertNoActiveAllocations(classroomId: number) {
|
||||
const today = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(new Date());
|
||||
const scheduleCount = await this.scheduleRepo
|
||||
.createQueryBuilder('schedule')
|
||||
.where('schedule.classroomId = :classroomId', { classroomId })
|
||||
.andWhere('schedule.status = :active', { active: 'active' })
|
||||
.andWhere('schedule.endDate >= :today', { today })
|
||||
.getCount();
|
||||
const rentalCount = await this.rentalRepo.count({
|
||||
where: {
|
||||
classroomId,
|
||||
status: ClassroomRentalStatus.ACTIVE,
|
||||
endDate: MoreThanOrEqual(today),
|
||||
},
|
||||
});
|
||||
if (rentalCount > 0 || scheduleCount > 0) {
|
||||
throw new BadRequestException('该教室存在有效排课或租赁,无法维护或归档');
|
||||
}
|
||||
}
|
||||
|
||||
private async getUsageForClassrooms(classroomIds: number[]): Promise<
|
||||
Map<
|
||||
number,
|
||||
{
|
||||
state: 'in_use' | 'reserved';
|
||||
currentUsage: {
|
||||
type: 'schedule' | 'rental';
|
||||
title: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
} | null;
|
||||
}
|
||||
>
|
||||
> {
|
||||
const result = new Map<
|
||||
number,
|
||||
{
|
||||
state: 'in_use' | 'reserved';
|
||||
currentUsage: {
|
||||
type: 'schedule' | 'rental';
|
||||
title: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
} | null;
|
||||
}
|
||||
>();
|
||||
if (classroomIds.length === 0) return result;
|
||||
|
||||
const now = new Date();
|
||||
const todayStr = now.toISOString().slice(0, 10);
|
||||
const currentTime = now.toTimeString().slice(0, 5);
|
||||
const weekDay = now.getDay() || 7;
|
||||
const todayStr = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(now);
|
||||
const currentTime = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(now);
|
||||
const shanghaiParts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
weekday: 'short',
|
||||
}).format(now);
|
||||
const weekDayMap: Record<string, number> = {
|
||||
Mon: 1,
|
||||
Tue: 2,
|
||||
Wed: 3,
|
||||
Thu: 4,
|
||||
Fri: 5,
|
||||
Sat: 6,
|
||||
Sun: 7,
|
||||
};
|
||||
const weekDay = weekDayMap[shanghaiParts];
|
||||
|
||||
const schedules = await this.scheduleRepo
|
||||
.createQueryBuilder('s')
|
||||
@@ -71,26 +169,37 @@ export class ClassroomsService {
|
||||
.select('s.classroomId', 'classroomId')
|
||||
.addSelect('s.startTime', 'startTime')
|
||||
.addSelect('s.endTime', 'endTime')
|
||||
.addSelect('s.startDate', 'startDate')
|
||||
.addSelect('s.endDate', 'endDate')
|
||||
.addSelect('s.weekDay', 'weekDay')
|
||||
.addSelect('s.subject', 'subject')
|
||||
.addSelect('c.name', 'className')
|
||||
.where('s.classroomId IN (:...ids)', { ids: classroomIds })
|
||||
.andWhere('s.status = :active', { active: 'active' })
|
||||
.andWhere('s.scheduleType = :type', { type: 'INTERNAL' })
|
||||
.andWhere('s.startDate <= :today', { today: todayStr })
|
||||
.andWhere('s.endDate >= :today', { today: todayStr })
|
||||
.andWhere('s.weekDay = :weekDay', { weekDay })
|
||||
.andWhere('s.startTime <= :currentTime', { currentTime })
|
||||
.andWhere('s.endTime >= :currentTime', { currentTime })
|
||||
.getRawMany();
|
||||
|
||||
for (const s of schedules) {
|
||||
const classroomId = Number(s.classroomId);
|
||||
if (!result.has(classroomId)) {
|
||||
for (const schedule of schedules) {
|
||||
const classroomId = Number(schedule.classroomId);
|
||||
const isCurrent =
|
||||
String(schedule.startDate) <= todayStr &&
|
||||
String(schedule.endDate) >= todayStr &&
|
||||
Number(schedule.weekDay) === weekDay &&
|
||||
String(schedule.startTime) <= currentTime &&
|
||||
String(schedule.endTime) >= currentTime;
|
||||
const existing = result.get(classroomId);
|
||||
if (!existing || isCurrent) {
|
||||
result.set(classroomId, {
|
||||
type: 'schedule',
|
||||
title: `${s.className || ''} ${s.subject || ''}`.trim() || '内部课程',
|
||||
startTime: String(s.startTime),
|
||||
endTime: String(s.endTime),
|
||||
state: isCurrent ? 'in_use' : 'reserved',
|
||||
currentUsage: isCurrent
|
||||
? {
|
||||
type: 'schedule',
|
||||
title: `${schedule.className || ''} ${schedule.subject || ''}`.trim() || '内部课程',
|
||||
startTime: String(schedule.startTime),
|
||||
endTime: String(schedule.endTime),
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -103,19 +212,25 @@ export class ClassroomsService {
|
||||
.addSelect('r.endDate', 'endDate')
|
||||
.addSelect('t.name', 'tenantName')
|
||||
.where('r.classroomId IN (:...ids)', { ids: classroomIds })
|
||||
.andWhere('r.status != :cancelled', { cancelled: 'cancelled' })
|
||||
.andWhere('r.startDate <= :today', { today: todayStr })
|
||||
.andWhere('r.status = :active', { active: ClassroomRentalStatus.ACTIVE })
|
||||
.andWhere('r.endDate >= :today', { today: todayStr })
|
||||
.getRawMany();
|
||||
|
||||
for (const r of rentals) {
|
||||
const classroomId = Number(r.classroomId);
|
||||
if (!result.has(classroomId)) {
|
||||
for (const rental of rentals) {
|
||||
const classroomId = Number(rental.classroomId);
|
||||
const isCurrent = String(rental.startDate) <= todayStr && String(rental.endDate) >= todayStr;
|
||||
const existing = result.get(classroomId);
|
||||
if (!existing || isCurrent) {
|
||||
result.set(classroomId, {
|
||||
type: 'rental',
|
||||
title: r.tenantName ? `${r.tenantName} 租赁` : '外部租赁',
|
||||
startTime: '00:00',
|
||||
endTime: '23:59',
|
||||
state: isCurrent ? 'in_use' : 'reserved',
|
||||
currentUsage: isCurrent
|
||||
? {
|
||||
type: 'rental',
|
||||
title: rental.tenantName ? `${rental.tenantName} 租赁` : '外部租赁',
|
||||
startTime: '00:00',
|
||||
endTime: '23:59',
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -136,24 +251,38 @@ export class ClassroomsService {
|
||||
let skipped = 0;
|
||||
const errors: string[] = [];
|
||||
for (const row of rows) {
|
||||
if (!row.name?.trim()) { skipped++; continue; }
|
||||
if (!row.name?.trim()) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const exists = await this.repo.findOne({ where: { name: row.name.trim() } });
|
||||
if (exists) { errors.push(`教室 ${row.name} 已存在`); skipped++; continue; }
|
||||
if (exists) {
|
||||
errors.push(`教室 ${row.name} 已存在`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
await this.repo.save(this.repo.create({ ...row, capacity: row.capacity || 30 }));
|
||||
imported++;
|
||||
}
|
||||
return { message: `成功导入 ${imported} 间教室,跳过 ${skipped} 间`, imported, skipped, errors: errors.length > 0 ? errors : undefined };
|
||||
return {
|
||||
message: `成功导入 ${imported} 间教室,跳过 ${skipped} 间`,
|
||||
imported,
|
||||
skipped,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async getUsageReport(dateFrom: string, dateTo: string) {
|
||||
const classrooms = await this.repo.find({
|
||||
where: { status: Not('archived') },
|
||||
where: { status: Not(ClassroomStatus.ARCHIVED) },
|
||||
order: { building: 'ASC', name: 'ASC' },
|
||||
});
|
||||
|
||||
const rentals = await this.rentalRepo
|
||||
.createQueryBuilder('r')
|
||||
.where('r.status != :cancelled', { cancelled: 'cancelled' })
|
||||
.where('r.status IN (:...statuses)', {
|
||||
statuses: [ClassroomRentalStatus.ACTIVE, ClassroomRentalStatus.ENDED],
|
||||
})
|
||||
.andWhere('r.startDate <= :dateTo AND r.endDate >= :dateFrom', { dateFrom, dateTo })
|
||||
.getMany();
|
||||
|
||||
|
||||
39
apps/server/src/classrooms/classrooms.status.spec.ts
Normal file
39
apps/server/src/classrooms/classrooms.status.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ClassroomStatus } from '../entities/classroom.entity';
|
||||
import { ClassroomsService } from './classrooms.service';
|
||||
|
||||
function createService(options?: { rentals?: number; schedules?: number }) {
|
||||
const repo = {
|
||||
findOne: jest.fn().mockResolvedValue({ id: 1, name: 'A101', status: ClassroomStatus.ARCHIVED }),
|
||||
update: jest.fn(),
|
||||
};
|
||||
const rentalRepo = { count: jest.fn().mockResolvedValue(options?.rentals ?? 0) };
|
||||
const scheduleRepo = {
|
||||
createQueryBuilder: jest.fn().mockReturnValue({
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
getCount: jest.fn().mockResolvedValue(options?.schedules ?? 0),
|
||||
}),
|
||||
};
|
||||
return {
|
||||
service: new ClassroomsService(repo as never, rentalRepo as never, scheduleRepo as never),
|
||||
repo,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ClassroomsService — persisted classroom status', () => {
|
||||
it('restores an archived classroom to available', async () => {
|
||||
const { service, repo } = createService();
|
||||
|
||||
await service.restore(1);
|
||||
|
||||
expect(repo.update).toHaveBeenCalledWith(1, { status: ClassroomStatus.AVAILABLE });
|
||||
});
|
||||
|
||||
it('rejects archiving a classroom with active allocations', async () => {
|
||||
const { service, repo } = createService({ rentals: 1 });
|
||||
|
||||
await expect(service.remove(1)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(repo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IsOptional, IsString, IsNotEmpty, IsInt, IsEnum } from 'class-validator';
|
||||
import { ClassroomStatus } from '../../entities/classroom.entity';
|
||||
|
||||
export class CreateClassroomDto {
|
||||
@IsString()
|
||||
@@ -21,11 +22,9 @@ export class CreateClassroomDto {
|
||||
@IsString()
|
||||
roomType?: string; // 大 / 次大 / 小
|
||||
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
}
|
||||
|
||||
export class UpdateClassroomDto {
|
||||
@@ -49,12 +48,11 @@ export class UpdateClassroomDto {
|
||||
@IsString()
|
||||
roomType?: string;
|
||||
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['available', 'archived'])
|
||||
status?: string;
|
||||
@IsEnum([ClassroomStatus.AVAILABLE, ClassroomStatus.MAINTENANCE])
|
||||
status?: ClassroomStatus;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user