feat: replace tenants with organization management

This commit is contained in:
2026-07-10 21:27:26 +08:00
parent 8ed1682b90
commit 8f0991a51f
49 changed files with 1292 additions and 698 deletions

View File

@@ -36,13 +36,13 @@ export class ClassroomRentalsController {
@RequirePermission('rental:view')
findAll(
@Query('classroomId') classroomId?: string,
@Query('tenantId') tenantId?: string,
@Query('lesseeOrganizationId') lesseeOrganizationId?: string,
@Query('month') month?: string,
@Query('includeEnded') includeEnded?: string,
) {
return this.service.findAll({
classroomId: classroomId ? +classroomId : undefined,
tenantId: tenantId ? +tenantId : undefined,
lesseeOrganizationId: lesseeOrganizationId ? +lesseeOrganizationId : undefined,
month,
includeEnded: includeEnded === 'true',
});
@@ -79,10 +79,18 @@ export class ClassroomRentalsController {
throw new BadRequestException('月份必须在 1-12 之间');
}
const parsedExcludeId = excludeId === undefined ? undefined : Number(excludeId);
if (parsedExcludeId !== undefined && (!Number.isInteger(parsedExcludeId) || parsedExcludeId <= 0)) {
if (
parsedExcludeId !== undefined &&
(!Number.isInteger(parsedExcludeId) || parsedExcludeId <= 0)
) {
throw new BadRequestException('排除的租赁订单不合法');
}
return this.service.getUnavailableDates(parsedClassroomId, parsedYear, parsedMonth, parsedExcludeId);
return this.service.getUnavailableDates(
parsedClassroomId,
parsedYear,
parsedMonth,
parsedExcludeId,
);
}
@Get(':id')
@@ -103,7 +111,7 @@ export class ClassroomRentalsController {
action: '新增租赁',
targetId: result.id,
targetType: 'classroom-rental',
detail: `教室${dto.classroomId} 租赁方${dto.tenantId} ${dto.startDate}~${dto.endDate}`,
detail: `教室${dto.classroomId} 承租机构${dto.lesseeOrganizationId} ${dto.startDate}~${dto.endDate}`,
ipAddress,
userAgent,
});

View File

@@ -2,14 +2,17 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ClassroomRental } from '../entities/classroom-rental.entity';
import { Classroom } from '../entities/classroom.entity';
import { Tenant } from '../entities/tenant.entity';
import { Organization } from '../entities/organization.entity';
import { ClassSchedule } from '../entities/class-schedule.entity';
import { ClassroomRentalsService } from './classroom-rentals.service';
import { ClassroomRentalsController } from './classroom-rentals.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
@Module({
imports: [TypeOrmModule.forFeature([ClassroomRental, Classroom, Tenant, ClassSchedule]), OperationLogsModule],
imports: [
TypeOrmModule.forFeature([ClassroomRental, Classroom, Organization, ClassSchedule]),
OperationLogsModule,
],
controllers: [ClassroomRentalsController],
providers: [ClassroomRentalsService],
exports: [ClassroomRentalsService],

View File

@@ -5,7 +5,7 @@ import { Not, Repository } from 'typeorm';
import { ClassroomRentalsService } from './classroom-rentals.service';
import { ClassroomRental } from '../entities/classroom-rental.entity';
import { Classroom } from '../entities/classroom.entity';
import { Tenant } from '../entities/tenant.entity';
import { Organization } from '../entities/organization.entity';
import { ClassSchedule } from '../entities/class-schedule.entity';
import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto';
@@ -28,9 +28,12 @@ describe('ClassroomRentalsService — findConflicts', () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ClassroomRentalsService,
{ provide: getRepositoryToken(ClassroomRental), useValue: { createQueryBuilder: jest.fn() } },
{
provide: getRepositoryToken(ClassroomRental),
useValue: { createQueryBuilder: jest.fn() },
},
{ provide: getRepositoryToken(Classroom), useValue: {} },
{ provide: getRepositoryToken(Tenant), useValue: {} },
{ provide: getRepositoryToken(Organization), useValue: {} },
{ provide: getRepositoryToken(ClassSchedule), useValue: { createQueryBuilder: jest.fn() } },
],
}).compile();
@@ -41,7 +44,12 @@ describe('ClassroomRentalsService — findConflicts', () => {
});
it('returns rental conflicts when no schedule conflicts exist', async () => {
const rental = { id: 1, startDate: '2026-03-01', endDate: '2026-03-31', tenant: { name: 'A机构' } } as ClassroomRental;
const rental = {
id: 1,
startDate: '2026-03-01',
endDate: '2026-03-31',
organization: { name: 'A机构' },
} as ClassroomRental;
const rentalQb = mockQueryBuilder<ClassroomRental>([rental]);
const scheduleQb = mockQueryBuilder<ClassSchedule>([]);
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb);
@@ -56,18 +64,32 @@ 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: '数学', weekDay: 1, 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);
await expect(service.findConflicts(1, '2026-03-15', '2026-04-15')).rejects.toThrow(ConflictException);
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,
{
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);
@@ -111,7 +133,7 @@ describe('ClassroomRentalsService — unavailable dates', () => {
ClassroomRentalsService,
{ provide: getRepositoryToken(ClassroomRental), useValue: { find: jest.fn() } },
{ provide: getRepositoryToken(Classroom), useValue: {} },
{ provide: getRepositoryToken(Tenant), useValue: {} },
{ provide: getRepositoryToken(Organization), useValue: {} },
{ provide: getRepositoryToken(ClassSchedule), useValue: { find: jest.fn() } },
],
}).compile();
@@ -151,12 +173,18 @@ describe('ClassroomRentalsService — unavailable dates', () => {
describe('ClassroomRentalsService — rental schedule sync', () => {
let service: ClassroomRentalsService;
let rentalRepo: jest.Mocked<
Pick<Repository<ClassroomRental>, 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'>
Pick<
Repository<ClassroomRental>,
'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'
>
>;
let classroomRepo: jest.Mocked<Pick<Repository<Classroom>, 'findOne'>>;
let tenantRepo: jest.Mocked<Pick<Repository<Tenant>, 'findOne'>>;
let organizationRepo: jest.Mocked<Pick<Repository<Organization>, 'findOne'>>;
let scheduleRepo: jest.Mocked<
Pick<Repository<ClassSchedule>, 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'>
Pick<
Repository<ClassSchedule>,
'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'
>
>;
beforeEach(async () => {
@@ -168,12 +196,17 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
delete: jest.fn(),
createQueryBuilder: jest.fn(),
} as jest.Mocked<
Pick<Repository<ClassroomRental>, 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'>
Pick<
Repository<ClassroomRental>,
'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'
>
>;
classroomRepo = { findOne: jest.fn() } as jest.Mocked<Pick<Repository<Classroom>, 'findOne'>>;
tenantRepo = { findOne: jest.fn() } as jest.Mocked<Pick<Repository<Tenant>, 'findOne'>>;
organizationRepo = { findOne: jest.fn() } as jest.Mocked<
Pick<Repository<Organization>, 'findOne'>
>;
scheduleRepo = {
findOne: jest.fn(),
@@ -183,7 +216,10 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
delete: jest.fn(),
createQueryBuilder: jest.fn(),
} as jest.Mocked<
Pick<Repository<ClassSchedule>, 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'>
Pick<
Repository<ClassSchedule>,
'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'
>
>;
const module: TestingModule = await Test.createTestingModule({
@@ -191,7 +227,7 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
ClassroomRentalsService,
{ provide: getRepositoryToken(ClassroomRental), useValue: rentalRepo },
{ provide: getRepositoryToken(Classroom), useValue: classroomRepo },
{ provide: getRepositoryToken(Tenant), useValue: tenantRepo },
{ provide: getRepositoryToken(Organization), useValue: organizationRepo },
{ provide: getRepositoryToken(ClassSchedule), useValue: scheduleRepo },
],
}).compile();
@@ -203,22 +239,43 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
it('saves the rental and creates a RENTAL class_schedule row', async () => {
const dto: CreateRentalDto = {
classroomId: 1,
tenantId: 2,
lesseeOrganizationId: 2,
startDate: '2026-03-01',
endDate: '2026-03-31',
};
const classroom = { id: 1, departmentId: 10 } as Classroom;
const tenant = { id: 2, name: 'Tenant A' } as Tenant;
const hostOrganization = {
id: 1,
name: 'Host',
isHost: true,
status: 'active',
} as Organization;
const organization = {
id: 2,
name: 'Organization A',
isHost: false,
status: 'active',
} as Organization;
classroomRepo.findOne.mockResolvedValue(classroom);
tenantRepo.findOne.mockResolvedValue(tenant);
rentalRepo.create.mockImplementation((entity) => ({ ...(entity as object) } as ClassroomRental));
rentalRepo.save.mockImplementation((entity) => Promise.resolve({ ...(entity as object), id: 1 } as ClassroomRental));
organizationRepo.findOne
.mockResolvedValueOnce(hostOrganization)
.mockResolvedValueOnce(organization);
rentalRepo.create.mockImplementation(
(entity) => ({ ...(entity as object) }) as ClassroomRental,
);
rentalRepo.save.mockImplementation((entity) =>
Promise.resolve({ ...(entity as object), id: 1 } as ClassroomRental),
);
rentalRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder<ClassroomRental>([]));
scheduleRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder<ClassSchedule>([]));
scheduleRepo.findOne.mockResolvedValue(null);
scheduleRepo.create.mockImplementation((entity) => ({ ...(entity as object) } as ClassSchedule));
scheduleRepo.save.mockImplementation((entity) => Promise.resolve({ ...(entity as object), id: 100 } as ClassSchedule));
scheduleRepo.create.mockImplementation(
(entity) => ({ ...(entity as object) }) as ClassSchedule,
);
scheduleRepo.save.mockImplementation((entity) =>
Promise.resolve({ ...(entity as object), id: 100 } as ClassSchedule),
);
const result = await service.create(dto);
@@ -226,11 +283,11 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
expect(rentalRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
classroomId: 1,
tenantId: 2,
lessorOrganizationId: 1,
lesseeOrganizationId: 2,
startDate: '2026-03-01',
endDate: '2026-03-31',
status: 'active',
departmentId: 10,
}),
);
expect(scheduleRepo.create).toHaveBeenCalledWith(
@@ -241,12 +298,11 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
endTime: '23:59',
startDate: '2026-03-01',
endDate: '2026-03-31',
subject: 'Tenant A 租赁',
subject: 'Organization A 租赁',
teacherId: null,
scheduleType: 'RENTAL',
rentalId: 1,
status: 'active',
departmentId: 10,
}),
);
expect(scheduleRepo.save).toHaveBeenCalled();
@@ -258,13 +314,12 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
const existingRental = {
id: 1,
classroomId: 1,
tenantId: 2,
lesseeOrganizationId: 2,
startDate: '2026-03-01',
endDate: '2026-03-31',
status: 'active',
notes: '',
departmentId: 10,
tenant: { id: 2, name: 'Tenant A' } as Tenant,
lesseeOrganization: { id: 2, name: 'Organization A' } as Organization,
classroom: { id: 1 } as Classroom,
} as ClassroomRental;
const updatedRental = {
@@ -272,7 +327,12 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
startDate: '2026-04-01',
endDate: '2026-04-30',
} as ClassroomRental;
const existingSchedule = { id: 50, rentalId: 1, scheduleType: 'RENTAL', classroomId: 1 } as ClassSchedule;
const existingSchedule = {
id: 50,
rentalId: 1,
scheduleType: 'RENTAL',
classroomId: 1,
} as ClassSchedule;
rentalRepo.findOne.mockResolvedValueOnce(existingRental).mockResolvedValueOnce(updatedRental);
rentalRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder<ClassroomRental>([]));
@@ -295,7 +355,7 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
startDate: '2026-04-01',
endDate: '2026-04-30',
status: 'active',
subject: 'Tenant A 租赁',
subject: 'Organization A 租赁',
}),
);
expect(scheduleRepo.create).not.toHaveBeenCalled();
@@ -306,12 +366,11 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
const rental = {
id: 1,
classroomId: 1,
tenantId: 2,
lesseeOrganizationId: 2,
startDate: '2026-03-01',
endDate: '2026-03-31',
status: 'active',
departmentId: 10,
tenant: { id: 2, name: 'Tenant A' } as Tenant,
lesseeOrganization: { id: 2, name: 'Organization A' } as Organization,
} as ClassroomRental;
const cancelledRental = { ...rental, status: 'cancelled' } as ClassroomRental;
@@ -332,12 +391,11 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
const rental = {
id: 1,
classroomId: 1,
tenantId: 2,
lesseeOrganizationId: 2,
startDate: '2026-03-01',
endDate: '2026-03-31',
status: 'active',
departmentId: 10,
tenant: { id: 2, name: 'Tenant A' } as Tenant,
lesseeOrganization: { id: 2, name: 'Organization A' } as Organization,
} as ClassroomRental;
rentalRepo.findOne.mockResolvedValue(rental);
@@ -349,3 +407,55 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
});
});
});
describe('ClassroomRentalsService — organization roles', () => {
it('stores explicit lessor and lessee organizations for a rental', async () => {
const rentalRepo = {
findOne: jest.fn(),
save: jest.fn(async (value) => ({ ...value, id: 1 })),
create: jest.fn((value) => value),
update: jest.fn(),
delete: jest.fn(),
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder<ClassroomRental>([])),
} as any;
const classroomRepo = {
findOne: jest.fn().mockResolvedValue({ id: 1, departmentId: 10 }),
} as any;
const organizationRepo = {
findOne: jest
.fn()
.mockResolvedValueOnce({ id: 1, name: '本机构', isHost: true, status: 'active' })
.mockResolvedValueOnce({ id: 2, name: '合作机构', isHost: false, status: 'active' }),
} as any;
const scheduleRepo = {
findOne: jest.fn().mockResolvedValue(null),
save: jest.fn(async (value) => ({ ...value, id: 100 })),
create: jest.fn((value) => value),
update: jest.fn(),
delete: jest.fn(),
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder<ClassSchedule>([])),
} as any;
const service = new ClassroomRentalsService(
rentalRepo,
classroomRepo,
organizationRepo,
scheduleRepo,
);
await service.create({
classroomId: 1,
lessorOrganizationId: 1,
lesseeOrganizationId: 2,
startDate: '2026-08-01',
endDate: '2026-08-31',
} as any);
expect(rentalRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
lessorOrganizationId: 1,
lesseeOrganizationId: 2,
}),
);
});
});

View File

@@ -8,14 +8,14 @@ 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 { Tenant } from '../entities/tenant.entity';
import { Organization } from '../entities/organization.entity';
import { ClassSchedule } from '../entities/class-schedule.entity';
import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto';
import * as path from 'path';
import * as fs from 'fs';
// 预设色板(与 tenants.service 保持一致,作为颜色兜底)
// 预设色板(与 organizations.service 保持一致,作为颜色兜底)
const COLOR_PALETTE = [
'#ff7875',
'#ffa940',
@@ -31,11 +31,10 @@ const COLOR_PALETTE = [
@Injectable()
export class ClassroomRentalsService {
constructor(
@InjectRepository(ClassroomRental) private repo: Repository<ClassroomRental>,
@InjectRepository(Classroom) private classroomRepo: Repository<Classroom>,
@InjectRepository(Tenant) private tenantRepo: Repository<Tenant>,
@InjectRepository(Organization) private organizationRepo: Repository<Organization>,
@InjectRepository(ClassSchedule) private scheduleRepo: Repository<ClassSchedule>,
) {}
@@ -52,17 +51,18 @@ export class ClassroomRentalsService {
async findAll(query?: {
classroomId?: number;
tenantId?: number;
lesseeOrganizationId?: number;
month?: string;
includeEnded?: boolean;
}) {
const qb = this.repo
.createQueryBuilder('r')
.leftJoinAndSelect('r.classroom', 'classroom')
.leftJoinAndSelect('r.tenant', 'tenant')
.leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization')
.orderBy('r.startDate', 'DESC');
if (query?.classroomId) qb.andWhere('r.classroomId = :cid', { cid: query.classroomId });
if (query?.tenantId) qb.andWhere('r.tenantId = :tid', { tid: query.tenantId });
if (query?.lesseeOrganizationId)
qb.andWhere('r.lesseeOrganizationId = :oid', { oid: query.lesseeOrganizationId });
if (query?.month) {
const [y, m] = query.month.split('-').map(Number);
const first = `${y}-${String(m).padStart(2, '0')}-01`;
@@ -75,7 +75,10 @@ export class ClassroomRentalsService {
}
async findOne(id: number) {
const rental = await this.repo.findOne({ where: { id }, relations: ['classroom', 'tenant'] });
const rental = await this.repo.findOne({
where: { id },
relations: ['classroom', 'lessorOrganization', 'lesseeOrganization'],
});
if (!rental) throw new NotFoundException('租赁订单不存在');
return rental;
}
@@ -128,7 +131,7 @@ export class ClassroomRentalsService {
async findConflicts(classroomId: number, startDate: string, endDate: string, excludeId?: number) {
const qb = this.repo
.createQueryBuilder('r')
.leftJoinAndSelect('r.tenant', 'tenant')
.leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization')
.where('r.classroomId = :cid', { cid: classroomId })
.andWhere('r.status != :cancelled', { cancelled: 'cancelled' })
.andWhere('r.startDate <= :end', { end: endDate })
@@ -156,7 +159,7 @@ export class ClassroomRentalsService {
id: s.id,
startDate: s.startDate,
endDate: s.endDate,
tenantName: `[内部排课] ${s.subject}`,
organizationName: `[内部排课] ${s.subject}`,
})),
});
}
@@ -164,7 +167,11 @@ export class ClassroomRentalsService {
return rentals;
}
private hasScheduleOccurrence(schedule: ClassSchedule, startDate: string, endDate: string): boolean {
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;
@@ -191,7 +198,12 @@ export class ClassroomRentalsService {
}
}
private addScheduleOccurrences(dates: Set<string>, schedule: ClassSchedule, startDate: string, endDate: string) {
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;
@@ -210,8 +222,19 @@ 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('教室不存在');
const tenant = await this.tenantRepo.findOne({ where: { id: dto.tenantId } });
if (!tenant) throw new NotFoundException('租赁方不存在');
const lessorOrganization = dto.lessorOrganizationId
? await this.organizationRepo.findOne({
where: { id: dto.lessorOrganizationId, status: 'active' },
})
: await this.organizationRepo.findOne({ where: { isHost: true, status: 'active' } });
if (!lessorOrganization) throw new NotFoundException('出租机构不存在或未启用');
const lesseeOrganization = await this.organizationRepo.findOne({
where: { id: dto.lesseeOrganizationId, status: 'active' },
});
if (!lesseeOrganization) throw new NotFoundException('承租机构不存在或未启用');
if (lessorOrganization.id === lesseeOrganization.id) {
throw new BadRequestException('出租机构和承租机构不能相同');
}
const conflicts = await this.findConflicts(dto.classroomId, dto.startDate, dto.endDate);
if (conflicts.length > 0) {
@@ -221,12 +244,19 @@ export class ClassroomRentalsService {
id: c.id,
startDate: c.startDate,
endDate: c.endDate,
tenantName: c.tenant?.name,
organizationName: c.lesseeOrganization?.name,
})),
});
}
const rental = this.repo.create({ ...dto, createdBy: userId, status: 'active' });
const rental = this.repo.create({
...dto,
lessorOrganizationId: lessorOrganization.id,
lesseeOrganizationId: lesseeOrganization.id,
createdBy: userId,
status: 'active',
});
const saved = await this.repo.save(rental);
await this.syncScheduleFromRental(saved, lesseeOrganization.name);
return saved;
}
@@ -246,11 +276,28 @@ export class ClassroomRentalsService {
id: c.id,
startDate: c.startDate,
endDate: c.endDate,
tenantName: c.tenant?.name,
organizationName: c.lesseeOrganization?.name,
})),
});
}
}
const newLessorId = dto.lessorOrganizationId ?? rental.lessorOrganizationId;
const newLesseeId = dto.lesseeOrganizationId ?? rental.lesseeOrganizationId;
if (newLessorId === newLesseeId) {
throw new BadRequestException('出租机构和承租机构不能相同');
}
if (dto.lessorOrganizationId) {
const lessor = await this.organizationRepo.findOne({
where: { id: dto.lessorOrganizationId, status: 'active' },
});
if (!lessor) throw new NotFoundException('出租机构不存在或未启用');
}
if (dto.lesseeOrganizationId) {
const lessee = await this.organizationRepo.findOne({
where: { id: dto.lesseeOrganizationId, status: 'active' },
});
if (!lessee) throw new NotFoundException('承租机构不存在或未启用');
}
await this.repo.update(id, dto);
const updated = await this.findOne(id);
if (dto.status === 'cancelled') {
@@ -283,8 +330,8 @@ export class ClassroomRentalsService {
/**
* 同步租赁订单到 class_schedulesschedule_type = 'RENTAL'
*/
private async syncScheduleFromRental(rental: ClassroomRental, tenantName?: string) {
const name = tenantName || rental.tenant?.name || '租赁方';
private async syncScheduleFromRental(rental: ClassroomRental, organizationName?: string) {
const name = organizationName || rental.lesseeOrganization?.name || '承租机构';
const weekDay = this.dateToWeekDay(rental.startDate);
let schedule = await this.scheduleRepo.findOne({
where: { rentalId: rental.id, scheduleType: 'RENTAL' },
@@ -395,13 +442,13 @@ export class ClassroomRentalsService {
});
const rentals = await this.repo
.createQueryBuilder('r')
.leftJoinAndSelect('r.tenant', 'tenant')
.leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization')
.leftJoinAndSelect('r.classroom', 'classroom')
.where('r.status != :cancelled', { cancelled: 'cancelled' })
.andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last })
.getMany();
const tenantMap = new Map<number, any>();
const organizationMap = new Map<number, any>();
const matrix: Record<number, Record<number, any>> = {};
const summary: Record<
number,
@@ -420,11 +467,13 @@ export class ClassroomRentalsService {
const monthEnd = new Date(last);
const effStart = start < monthStart ? monthStart : start;
const effEnd = end > monthEnd ? monthEnd : end;
if (rental.tenant && !tenantMap.has(rental.tenant.id)) {
tenantMap.set(rental.tenant.id, {
id: rental.tenant.id,
name: rental.tenant.name,
color: rental.tenant.color || COLOR_PALETTE[rental.tenant.id % COLOR_PALETTE.length],
if (rental.lesseeOrganization && !organizationMap.has(rental.lesseeOrganization.id)) {
organizationMap.set(rental.lesseeOrganization.id, {
id: rental.lesseeOrganization.id,
name: rental.lesseeOrganization.name,
color:
rental.lesseeOrganization.color ||
COLOR_PALETTE[rental.lesseeOrganization.id % COLOR_PALETTE.length],
});
}
for (let d = new Date(effStart); d <= effEnd; d.setDate(d.getDate() + 1)) {
@@ -433,10 +482,11 @@ export class ClassroomRentalsService {
matrix[rental.classroomId][day] = {
scheduleType: 'RENTAL',
rentalId: rental.id,
tenantId: rental.tenantId,
tenantName: rental.tenant?.name || '未知',
organizationId: rental.lesseeOrganizationId,
organizationName: rental.lesseeOrganization?.name || '未知',
color:
rental.tenant?.color || COLOR_PALETTE[(rental.tenantId || 0) % COLOR_PALETTE.length],
rental.lesseeOrganization?.color ||
COLOR_PALETTE[(rental.lesseeOrganizationId || 0) % COLOR_PALETTE.length],
hasContract: !!rental.contractPath,
};
}
@@ -454,8 +504,12 @@ export class ClassroomRentalsService {
for (const sched of schedules) {
if (!sched.classroomId) continue;
const schedStart = new Date(Math.max(new Date(sched.startDate).getTime(), new Date(first).getTime()));
const schedEnd = new Date(Math.min(new Date(sched.endDate).getTime(), new Date(last).getTime()));
const schedStart = new Date(
Math.max(new Date(sched.startDate).getTime(), new Date(first).getTime()),
);
const schedEnd = new Date(
Math.min(new Date(sched.endDate).getTime(), new Date(last).getTime()),
);
for (let d = new Date(schedStart); d <= schedEnd; d.setDate(d.getDate() + 1)) {
const dow = d.getDay() === 0 ? 7 : d.getDay();
if (dow !== sched.weekDay) continue;
@@ -494,7 +548,7 @@ export class ClassroomRentalsService {
capacity: c.capacity,
supervisor: c.supervisor,
})),
tenants: Array.from(tenantMap.values()),
organizations: Array.from(organizationMap.values()),
matrix,
summary,
};

View File

@@ -1,19 +1,15 @@
import {
IsOptional,
IsString,
IsNotEmpty,
IsInt,
IsNumber,
IsEnum,
IsDateString,
} from 'class-validator';
import { IsOptional, IsString, IsInt, IsNumber, IsEnum, IsDateString } from 'class-validator';
export class CreateRentalDto {
@IsInt()
classroomId: number;
@IsOptional()
@IsInt()
tenantId: number;
lessorOrganizationId?: number;
@IsInt()
lesseeOrganizationId: number;
@IsDateString()
startDate: string;
@@ -41,7 +37,11 @@ export class UpdateRentalDto {
@IsOptional()
@IsInt()
tenantId?: number;
lessorOrganizationId?: number;
@IsOptional()
@IsInt()
lesseeOrganizationId?: number;
@IsOptional()
@IsDateString()