fix permissions and teacher attendance workflows
This commit is contained in:
@@ -58,6 +58,33 @@ export class ClassroomRentalsController {
|
||||
return this.service.getSchedule(y, m);
|
||||
}
|
||||
|
||||
@Get('unavailable-dates')
|
||||
@RequirePermission('rental:view')
|
||||
getUnavailableDates(
|
||||
@Query('classroomId') classroomId?: string,
|
||||
@Query('year') year?: string,
|
||||
@Query('month') month?: string,
|
||||
@Query('excludeId') excludeId?: string,
|
||||
) {
|
||||
const parsedClassroomId = Number(classroomId);
|
||||
const parsedYear = Number(year);
|
||||
const parsedMonth = Number(month);
|
||||
if (!Number.isInteger(parsedClassroomId) || parsedClassroomId <= 0) {
|
||||
throw new BadRequestException('请选择有效教室');
|
||||
}
|
||||
if (!Number.isInteger(parsedYear) || parsedYear < 2000 || parsedYear > 2100) {
|
||||
throw new BadRequestException('年份不合法');
|
||||
}
|
||||
if (!Number.isInteger(parsedMonth) || parsedMonth < 1 || parsedMonth > 12) {
|
||||
throw new BadRequestException('月份必须在 1-12 之间');
|
||||
}
|
||||
const parsedExcludeId = excludeId === undefined ? undefined : Number(excludeId);
|
||||
if (parsedExcludeId !== undefined && (!Number.isInteger(parsedExcludeId) || parsedExcludeId <= 0)) {
|
||||
throw new BadRequestException('排除的租赁订单不合法');
|
||||
}
|
||||
return this.service.getUnavailableDates(parsedClassroomId, parsedYear, parsedMonth, parsedExcludeId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('rental:view')
|
||||
findOne(@Param('id') id: string) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Not, Repository } from 'typeorm';
|
||||
import { ClassroomRentalsService } from './classroom-rentals.service';
|
||||
import { ClassroomRental } from '../entities/classroom-rental.entity';
|
||||
import { Classroom } from '../entities/classroom.entity';
|
||||
@@ -56,7 +56,7 @@ describe('ClassroomRentalsService — findConflicts', () => {
|
||||
it('throws ConflictException when an active schedule overlaps the same classroom and date range', async () => {
|
||||
const rentalQb = mockQueryBuilder<ClassroomRental>([]);
|
||||
const scheduleQb = mockQueryBuilder<ClassSchedule>([
|
||||
{ id: 5, subject: '数学', startDate: '2026-03-01', endDate: '2026-06-30' } as ClassSchedule,
|
||||
{ id: 5, subject: '数学', weekDay: 1, startDate: '2026-03-01', endDate: '2026-06-30' } as ClassSchedule,
|
||||
]);
|
||||
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb);
|
||||
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(scheduleQb);
|
||||
@@ -64,6 +64,19 @@ describe('ClassroomRentalsService — findConflicts', () => {
|
||||
await expect(service.findConflicts(1, '2026-03-15', '2026-04-15')).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it('does not treat a weekly schedule as a conflict when its weekday does not occur in the rental range', async () => {
|
||||
const rentalQb = mockQueryBuilder<ClassroomRental>([]);
|
||||
const scheduleQb = mockQueryBuilder<ClassSchedule>([
|
||||
{ id: 5, subject: '数学', weekDay: 1, startDate: '2026-07-01', endDate: '2026-07-31' } as ClassSchedule,
|
||||
]);
|
||||
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb);
|
||||
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(scheduleQb);
|
||||
|
||||
const result = await service.findConflicts(1, '2026-07-01', '2026-07-05');
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not throw when schedule is outside the requested date range', async () => {
|
||||
const rentalQb = mockQueryBuilder<ClassroomRental>([]);
|
||||
const scheduleQb = mockQueryBuilder<ClassSchedule>([]);
|
||||
@@ -87,6 +100,54 @@ describe('ClassroomRentalsService — findConflicts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('ClassroomRentalsService — unavailable dates', () => {
|
||||
let service: ClassroomRentalsService;
|
||||
let rentalRepo: jest.Mocked<Pick<Repository<ClassroomRental>, 'find'>>;
|
||||
let scheduleRepo: jest.Mocked<Pick<Repository<ClassSchedule>, 'find'>>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ClassroomRentalsService,
|
||||
{ provide: getRepositoryToken(ClassroomRental), useValue: { find: jest.fn() } },
|
||||
{ provide: getRepositoryToken(Classroom), useValue: {} },
|
||||
{ provide: getRepositoryToken(Tenant), useValue: {} },
|
||||
{ provide: getRepositoryToken(ClassSchedule), useValue: { find: jest.fn() } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ClassroomRentalsService>(ClassroomRentalsService);
|
||||
rentalRepo = module.get(getRepositoryToken(ClassroomRental));
|
||||
scheduleRepo = module.get(getRepositoryToken(ClassSchedule));
|
||||
});
|
||||
|
||||
it('returns rental days and actual weekly schedule occurrence dates for a month', async () => {
|
||||
rentalRepo.find.mockResolvedValue([
|
||||
{ id: 10, startDate: '2026-07-03', endDate: '2026-07-04' } as ClassroomRental,
|
||||
]);
|
||||
scheduleRepo.find.mockResolvedValue([
|
||||
{ id: 5, weekDay: 1, startDate: '2026-07-01', endDate: '2026-07-31' } as ClassSchedule,
|
||||
]);
|
||||
|
||||
const result = await service.getUnavailableDates(1, 2026, 7);
|
||||
|
||||
expect(result).toEqual({
|
||||
dates: ['2026-07-03', '2026-07-04', '2026-07-06', '2026-07-13', '2026-07-20', '2026-07-27'],
|
||||
});
|
||||
});
|
||||
|
||||
it('excludes the rental being edited', async () => {
|
||||
rentalRepo.find.mockResolvedValue([]);
|
||||
scheduleRepo.find.mockResolvedValue([]);
|
||||
|
||||
await service.getUnavailableDates(1, 2026, 7, 99);
|
||||
|
||||
expect(rentalRepo.find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: expect.objectContaining({ id: Not(99) }) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
let service: ClassroomRentalsService;
|
||||
let rentalRepo: jest.Mocked<
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Not } from 'typeorm';
|
||||
import { Repository, Not, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
||||
import { ClassroomRental } from '../entities/classroom-rental.entity';
|
||||
import { Classroom } from '../entities/classroom.entity';
|
||||
import { Tenant } from '../entities/tenant.entity';
|
||||
@@ -80,6 +80,47 @@ export class ClassroomRentalsService {
|
||||
return rental;
|
||||
}
|
||||
|
||||
async getUnavailableDates(classroomId: number, year: number, month: number, excludeId?: number) {
|
||||
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||
const monthStart = `${year}-${String(month).padStart(2, '0')}-01`;
|
||||
const monthEnd = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
const [rentals, schedules] = await Promise.all([
|
||||
this.repo.find({
|
||||
where: {
|
||||
...(excludeId ? { id: Not(excludeId) } : {}),
|
||||
classroomId,
|
||||
status: Not('cancelled'),
|
||||
startDate: LessThanOrEqual(monthEnd),
|
||||
endDate: MoreThanOrEqual(monthStart),
|
||||
},
|
||||
}),
|
||||
this.scheduleRepo.find({
|
||||
where: {
|
||||
classroomId,
|
||||
status: 'active',
|
||||
scheduleType: 'INTERNAL',
|
||||
startDate: LessThanOrEqual(monthEnd),
|
||||
endDate: MoreThanOrEqual(monthStart),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const unavailableDates = new Set<string>();
|
||||
for (const rental of rentals) {
|
||||
this.addDateRange(
|
||||
unavailableDates,
|
||||
rental.startDate > monthStart ? rental.startDate : monthStart,
|
||||
rental.endDate < monthEnd ? rental.endDate : monthEnd,
|
||||
);
|
||||
}
|
||||
for (const schedule of schedules) {
|
||||
this.addScheduleOccurrences(unavailableDates, schedule, monthStart, monthEnd);
|
||||
}
|
||||
|
||||
return { dates: Array.from(unavailableDates).sort() };
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找与给定区间冲突的租赁订单,同时检测同一教室同一日期段的内部排课
|
||||
* 重叠判定:start1 <= end2 AND start2 <= end1
|
||||
@@ -96,13 +137,17 @@ export class ClassroomRentalsService {
|
||||
const rentals = await qb.getMany();
|
||||
|
||||
// 检测同一教室同一日期段是否存在内部排课
|
||||
const scheduleConflicts = await this.scheduleRepo
|
||||
const scheduleCandidates = await this.scheduleRepo
|
||||
.createQueryBuilder('cs')
|
||||
.where('cs.classroomId = :cid', { cid: classroomId })
|
||||
.andWhere('cs.status = :status', { status: 'active' })
|
||||
.andWhere('cs.scheduleType = :scheduleType', { scheduleType: 'INTERNAL' })
|
||||
.andWhere('cs.startDate <= :end', { end: endDate })
|
||||
.andWhere('cs.endDate >= :start', { start: startDate })
|
||||
.getMany();
|
||||
const scheduleConflicts = scheduleCandidates.filter((schedule) =>
|
||||
this.hasScheduleOccurrence(schedule, startDate, endDate),
|
||||
);
|
||||
|
||||
if (scheduleConflicts.length > 0) {
|
||||
throw new ConflictException({
|
||||
@@ -119,6 +164,48 @@ export class ClassroomRentalsService {
|
||||
return rentals;
|
||||
}
|
||||
|
||||
private hasScheduleOccurrence(schedule: ClassSchedule, startDate: string, endDate: string): boolean {
|
||||
const overlapStart = schedule.startDate > startDate ? schedule.startDate : startDate;
|
||||
const overlapEnd = schedule.endDate < endDate ? schedule.endDate : endDate;
|
||||
if (overlapStart > overlapEnd) return false;
|
||||
|
||||
const startUtc = this.toUtcDate(overlapStart);
|
||||
const endUtc = this.toUtcDate(overlapEnd);
|
||||
const startWeekDay = startUtc.getUTCDay() || 7;
|
||||
const daysUntilOccurrence = (schedule.weekDay - startWeekDay + 7) % 7;
|
||||
startUtc.setUTCDate(startUtc.getUTCDate() + daysUntilOccurrence);
|
||||
return startUtc <= endUtc;
|
||||
}
|
||||
|
||||
private toUtcDate(date: string): Date {
|
||||
const [year, month, day] = date.split('-').map(Number);
|
||||
return new Date(Date.UTC(year, month - 1, day));
|
||||
}
|
||||
|
||||
private addDateRange(dates: Set<string>, startDate: string, endDate: string) {
|
||||
const current = this.toUtcDate(startDate);
|
||||
const end = this.toUtcDate(endDate);
|
||||
while (current <= end) {
|
||||
dates.add(current.toISOString().slice(0, 10));
|
||||
current.setUTCDate(current.getUTCDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private addScheduleOccurrences(dates: Set<string>, schedule: ClassSchedule, startDate: string, endDate: string) {
|
||||
const overlapStart = schedule.startDate > startDate ? schedule.startDate : startDate;
|
||||
const overlapEnd = schedule.endDate < endDate ? schedule.endDate : endDate;
|
||||
if (overlapStart > overlapEnd) return;
|
||||
|
||||
const current = this.toUtcDate(overlapStart);
|
||||
const end = this.toUtcDate(overlapEnd);
|
||||
const startWeekDay = current.getUTCDay() || 7;
|
||||
current.setUTCDate(current.getUTCDate() + ((schedule.weekDay - startWeekDay + 7) % 7));
|
||||
while (current <= end) {
|
||||
dates.add(current.toISOString().slice(0, 10));
|
||||
current.setUTCDate(current.getUTCDate() + 7);
|
||||
}
|
||||
}
|
||||
|
||||
async create(dto: CreateRentalDto, userId?: number) {
|
||||
if (dto.startDate > dto.endDate) throw new BadRequestException('起始日期不能晚于结束日期');
|
||||
const classroom = await this.classroomRepo.findOne({ where: { id: dto.classroomId } });
|
||||
|
||||
Reference in New Issue
Block a user