fix permissions and teacher attendance workflows

This commit is contained in:
2026-07-10 20:40:52 +08:00
parent 247879f276
commit 8ed1682b90
95 changed files with 4745 additions and 1237 deletions

View File

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

View File

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

View File

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