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

@@ -19,7 +19,7 @@ import {
Deposit,
DepositInstallment,
Classroom,
Tenant,
Organization,
ClassroomRental,
Permission,
Role,
@@ -55,7 +55,7 @@ import { OperationLogsModule } from './operation-logs/operation-logs.module';
import { DepositsModule } from './deposits/deposits.module';
import { ClassroomsModule } from './classrooms/classrooms.module';
import { ClassesModule } from './classes/classes.module';
import { TenantsModule } from './tenants/tenants.module';
import { OrganizationsModule } from './organizations/organizations.module';
import { AttendanceModule } from './attendance/attendance.module';
import { SchedulesModule } from './schedules/schedules.module';
import { ClassroomRentalsModule } from './classroom-rentals/classroom-rentals.module';
@@ -65,7 +65,10 @@ import { ArchiveModule } from './archive/archive.module';
import { ExpenseTypesModule } from './expense-types/expense-types.module';
import { DatabaseMigrationsModule } from './database/database-migrations.module';
import { IntegrationConfig, IntegrationConfigDetail } from './integration/entities/integration-config.entity';
import {
IntegrationConfig,
IntegrationConfigDetail,
} from './integration/entities/integration-config.entity';
import { IntegrationConfigModule } from './integration/config/config.module';
@Module({
@@ -98,7 +101,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
Deposit,
DepositInstallment,
Classroom,
Tenant,
Organization,
ClassroomRental,
Class,
ClassStudent,
@@ -121,7 +124,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
StudentDingMapping,
IntegrationConfig,
IntegrationConfigDetail,
];
];
if (dbType === 'mysql') {
return {
type: 'mysql' as const,
@@ -157,7 +160,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
ClassroomsModule,
AttendanceModule,
ClassesModule,
TenantsModule,
OrganizationsModule,
SchedulesModule,
ClassroomRentalsModule,
SyncModule,

View File

@@ -94,7 +94,7 @@ describe('BillsService — generateBills', () => {
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-30',
rentalType: 'long',
stayType: 'long',
room: { roomNumber: '101', monthlyRate: '800' as unknown as number } as Room,
} as Occupancy,
]),
@@ -135,12 +135,12 @@ describe('BillsService — generateBills', () => {
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-10',
rentalType: 'short', room: undefined,
stayType: 'short', room: undefined,
} as Occupancy,
{
id: 2, studentId: 11, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-20',
rentalType: 'short', room: undefined,
stayType: 'short', room: undefined,
} as Occupancy,
]),
);
@@ -187,18 +187,18 @@ describe('BillsService — generateBills', () => {
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-30',
rentalType: 'long',
stayType: 'long',
room: { roomNumber: '101', monthlyRate: '600' as unknown as number } as Room,
} as Occupancy,
{
id: 2, studentId: 11, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-15',
rentalType: 'short', room: undefined,
stayType: 'short', room: undefined,
} as Occupancy,
{
id: 3, studentId: 12, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-15',
rentalType: 'short', room: undefined,
stayType: 'short', room: undefined,
} as Occupancy,
]),
);
@@ -247,7 +247,7 @@ describe('BillsService — generateBills', () => {
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-01-01', billingEndDate: '2026-03-31',
rentalType: 'long',
stayType: 'long',
room: { roomNumber: '101', monthlyRate: '800' as unknown as number } as Room,
} as Occupancy,
]),
@@ -289,7 +289,7 @@ describe('BillsService — generateBills', () => {
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-06-15', billingEndDate: '2026-06-30',
rentalType: 'long',
stayType: 'long',
room: { roomNumber: '101', monthlyRate: '600' as unknown as number } as Room,
} as Occupancy,
]),
@@ -343,12 +343,12 @@ describe('BillsService — generateBills', () => {
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-10',
rentalType: 'short', room: undefined,
stayType: 'short', room: undefined,
} as Occupancy,
{
id: 2, studentId: 11, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-20',
rentalType: 'short', room: undefined,
stayType: 'short', room: undefined,
} as Occupancy,
]);
}
@@ -356,7 +356,7 @@ describe('BillsService — generateBills', () => {
{
id: 3, studentId: 12, roomId: 2,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-30',
rentalType: 'short', room: undefined,
stayType: 'short', room: undefined,
} as Occupancy,
]);
});
@@ -409,7 +409,7 @@ describe('BillsService — generateBills', () => {
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-30',
rentalType: 'short', room: undefined,
stayType: 'short', room: undefined,
} as Occupancy,
]),
);
@@ -452,7 +452,7 @@ describe('BillsService — generateBills', () => {
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-07-01', billingEndDate: '2026-07-15',
rentalType: 'short', room: undefined,
stayType: 'short', room: undefined,
} as Occupancy,
]),
);
@@ -476,7 +476,7 @@ describe('BillsService — generateBills', () => {
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-30',
rentalType: 'short', room: undefined,
stayType: 'short', room: undefined,
} as Occupancy,
]),
);

View File

@@ -82,8 +82,8 @@ export class BillsService {
// 分离长租与短租入住记录
const shortTermOccs = occupancies.filter((o) => o.rentalType !== 'long');
const longTermOccs = occupancies.filter((o) => o.rentalType === 'long');
const shortTermOccs = occupancies.filter((o) => o.stayType !== 'long');
const longTermOccs = occupancies.filter((o) => o.stayType === 'long');
// 长租:按月租费独立计费,不参与人天数分摊
for (const occ of longTermOccs) {

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()

View File

@@ -97,7 +97,7 @@ export class ClassroomsService {
const rentals = await this.rentalRepo
.createQueryBuilder('r')
.leftJoin('Tenant', 't', 't.id = r.tenantId')
.leftJoin('Organization', 't', 't.id = r.lesseeOrganizationId')
.select('r.classroomId', 'classroomId')
.addSelect('r.startDate', 'startDate')
.addSelect('r.endDate', 'endDate')

View File

@@ -0,0 +1,11 @@
import { uuidV7 } from './uuid-v7';
describe('uuidV7', () => {
it('creates an RFC 9562 version 7 UUID with time-sortable prefixes', () => {
const first = uuidV7(1_700_000_000_000);
const second = uuidV7(1_700_000_000_001);
expect(first).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
expect(first < second).toBe(true);
});
});

View File

@@ -0,0 +1,21 @@
import { randomBytes } from 'node:crypto';
/** Generates an RFC 9562 UUIDv7 using the current Unix timestamp and cryptographic randomness. */
export function uuidV7(now = Date.now()): string {
const bytes = Buffer.alloc(16);
const random = randomBytes(10);
let timestamp = BigInt(now);
for (let index = 5; index >= 0; index -= 1) {
bytes[index] = Number(timestamp & 0xffn);
timestamp >>= 8n;
}
bytes[6] = 0x70 | (random[0] & 0x0f);
bytes[7] = random[1];
bytes[8] = 0x80 | (random[2] & 0x3f);
random.copy(bytes, 9, 3, 10);
const hex = bytes.toString('hex');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}

View File

@@ -1,5 +1,6 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { uuidV7 } from '../common/uuid-v7';
@Injectable()
export class DatabaseMigrationsService implements OnApplicationBootstrap {
@@ -8,9 +9,129 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
constructor(private readonly dataSource: DataSource) {}
async onApplicationBootstrap(): Promise<void> {
await this.backfillOrganizations();
await this.normalizeClassDates();
}
private async backfillOrganizations(): Promise<void> {
const runner = this.dataSource.createQueryRunner();
await runner.connect();
try {
const tables = await runner.getTables([
'tenants',
'organizations',
'students',
'occupancies',
'classroom_rentals',
]);
const tableNames = new Set(tables.map((table) => table.name));
if (!tableNames.has('organizations')) return;
const organizationRows = () =>
runner.query('SELECT * FROM organizations WHERE is_host = 1 LIMIT 1');
let host = (await organizationRows())[0];
if (!host) {
await runner.query(
`INSERT INTO organizations (public_id, code, name, is_host, color, notes, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
[
uuidV7(),
'HOST',
process.env.HOST_ORGANIZATION_NAME || '本机构',
1,
'#1677ff',
'系统默认运营主体',
'active',
],
);
host = (await organizationRows())[0];
}
if (!host) return;
if (tableNames.has('tenants')) {
const legacyTenants: Array<Record<string, unknown>> =
await runner.query('SELECT * FROM tenants');
for (const legacy of legacyTenants) {
const name = String(legacy.name || '').trim();
if (!name) continue;
let external = (
await runner.query('SELECT * FROM organizations WHERE name = ? LIMIT 1', [name])
)[0];
if (!external) {
await runner.query(
`INSERT INTO organizations (public_id, code, name, is_host, contact_name, phone, color, notes, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
[
uuidV7(),
`ORG_${legacy.id}`,
name,
0,
legacy.contact || null,
legacy.phone || null,
legacy.color || null,
legacy.notes || null,
legacy.status || 'active',
],
);
external = (
await runner.query('SELECT * FROM organizations WHERE name = ? LIMIT 1', [name])
)[0];
}
if (!external) continue;
if (tableNames.has('students')) {
await runner
.query(
'UPDATE students SET organization_id = ? WHERE organization_id IS NULL AND tenant_id = ?',
[external.id, legacy.id],
)
.catch(() => undefined);
}
if (tableNames.has('occupancies')) {
await runner
.query(
'UPDATE occupancies SET responsible_organization_id = ? WHERE responsible_organization_id IS NULL AND tenant_id = ?',
[external.id, legacy.id],
)
.catch(() => undefined);
}
if (tableNames.has('classroom_rentals')) {
await runner
.query(
'UPDATE classroom_rentals SET lessee_organization_id = ?, lessor_organization_id = ? WHERE lessee_organization_id IS NULL AND tenant_id = ?',
[external.id, host.id, legacy.id],
)
.catch(() => undefined);
}
}
}
if (tableNames.has('students')) {
await runner.query(
'UPDATE students SET organization_id = ? WHERE organization_id IS NULL',
[host.id],
);
}
if (tableNames.has('occupancies')) {
await runner.query(
`UPDATE occupancies
SET responsible_organization_id = COALESCE(
(SELECT organization_id FROM students WHERE students.id = occupancies.student_id), ?
)
WHERE responsible_organization_id IS NULL`,
[host.id],
);
}
if (tableNames.has('classroom_rentals')) {
await runner.query(
'UPDATE classroom_rentals SET lessor_organization_id = ? WHERE lessor_organization_id IS NULL',
[host.id],
);
}
} finally {
await runner.release();
}
}
private async normalizeClassDates(): Promise<void> {
const driver = this.dataSource.options.type;
const dateExpression = (column: string) =>

View File

@@ -9,7 +9,7 @@ import {
Index,
} from 'typeorm';
import { Classroom } from './classroom.entity';
import { Tenant } from './tenant.entity';
import { Organization } from './organization.entity';
@Entity('classroom_rentals')
@Index(['classroomId', 'startDate', 'endDate'])
@@ -24,12 +24,19 @@ export class ClassroomRental {
@JoinColumn({ name: 'classroom_id' })
classroom: Classroom;
@Column({ name: 'tenant_id' })
tenantId: number;
@Column({ name: 'lessor_organization_id', nullable: true })
lessorOrganizationId: number;
@ManyToOne(() => Tenant)
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@ManyToOne(() => Organization)
@JoinColumn({ name: 'lessor_organization_id' })
lessorOrganization: Organization;
@Column({ name: 'lessee_organization_id', nullable: true })
lesseeOrganizationId: number;
@ManyToOne(() => Organization)
@JoinColumn({ name: 'lessee_organization_id' })
lesseeOrganization: Organization;
@Column({ name: 'start_date', type: 'date' })
startDate: string;
@@ -64,5 +71,4 @@ export class ClassroomRental {
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@@ -12,7 +12,7 @@ export { OperationLog } from './operation-log.entity';
export { Deposit } from './deposit.entity';
export { DepositInstallment } from './deposit-installment.entity';
export { Classroom } from './classroom.entity';
export { Tenant } from './tenant.entity';
export { Organization } from './organization.entity';
export { ClassroomRental } from './classroom-rental.entity';
export { Permission } from './permission.entity';
export { Role } from './role.entity';

View File

@@ -8,7 +8,7 @@ import {
} from 'typeorm';
import { Student } from './student.entity';
import { Room } from './room.entity';
import { Tenant } from './tenant.entity';
import { Organization } from './organization.entity';
import { Bed } from './bed.entity';
import { Locker } from './locker.entity';
@@ -55,15 +55,15 @@ export class Occupancy {
@JoinColumn({ name: 'locker_id' })
locker: Locker;
@Column({ name: 'rental_type', length: 10, default: 'short' })
rentalType: string;
@Column({ name: 'stay_type', length: 10, default: 'short' })
stayType: string;
@Column({ name: 'tenant_id', type: 'integer', nullable: true })
tenantId: number;
@Column({ name: 'responsible_organization_id', type: 'integer', nullable: true })
responsibleOrganizationId: number;
@ManyToOne(() => Tenant, { nullable: true })
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@ManyToOne(() => Organization, { nullable: true })
@JoinColumn({ name: 'responsible_organization_id' })
responsibleOrganization: Organization;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@@ -75,5 +75,4 @@ export class Occupancy {
@ManyToOne(() => Room, (r) => r.occupancies)
@JoinColumn({ name: 'room_id' })
room: Room;
}

View File

@@ -6,21 +6,29 @@ import {
UpdateDateColumn,
} from 'typeorm';
@Entity('tenants')
export class Tenant {
@Entity('organizations')
export class Organization {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: 'public_id', type: 'varchar', length: 36, unique: true })
publicId: string;
@Column({ length: 50, unique: true })
code: string;
@Column({ length: 100 })
name: string;
@Column({ length: 50, nullable: true })
contact: string;
@Column({ name: 'is_host', default: false })
isHost: boolean;
@Column({ name: 'contact_name', length: 50, nullable: true })
contactName: string;
@Column({ length: 30, nullable: true })
phone: string;
// 可视化颜色hex为空时由后端自动分配
@Column({ length: 20, nullable: true })
color: string;
@@ -28,7 +36,7 @@ export class Tenant {
notes: string;
@Column({ type: 'varchar', length: 20, default: 'active' })
status: string; // active / archived
status: 'active' | 'archived';
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;

View File

@@ -12,7 +12,7 @@ import {
import { Occupancy } from './occupancy.entity';
import { PersonalExpense } from './personal-expense.entity';
import { Bill } from './bill.entity';
import { Tenant } from './tenant.entity';
import { Organization } from './organization.entity';
import { User } from './user.entity';
@Entity('students')
@@ -46,9 +46,6 @@ export class Student {
@Column({ type: 'varchar', length: 20, default: 'active' })
status: string;
@Column({ length: 100, nullable: true })
organization: string;
@Column({ length: 50, nullable: true })
supervisor: string;
@@ -68,17 +65,16 @@ export class Student {
@JoinColumn({ name: 'user_id' })
user: User;
@Column({ name: 'tenant_id', type: 'integer', nullable: true })
tenantId: number;
@Column({ name: 'organization_id', type: 'integer', nullable: true })
organizationId: number;
@ManyToOne(() => Tenant, { nullable: true })
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@ManyToOne(() => Organization, { nullable: true })
@JoinColumn({ name: 'organization_id' })
organization: Organization;
@OneToMany(() => PersonalExpense, (e) => e.student)
personalExpenses: PersonalExpense[];
@OneToMany(() => Bill, (b) => b.student)
bills: Bill[];
}

View File

@@ -20,14 +20,14 @@ export class CheckInDto {
@IsOptional()
@IsString()
rentalType?: string;
stayType?: string;
@IsOptional()
@IsInt()
tenantId?: number;
responsibleOrganizationId?: number;
@IsOptional()
@IsInt()
bedId?: number; // 后续改 required
bedId?: number; // 后续改 required
@IsOptional()
@IsInt()

View File

@@ -96,7 +96,9 @@ export class OccupanciesController {
content: `您已入住房间 #${dto.roomId}`,
});
}
} catch (_) { /* don't block response */ }
} catch (_) {
/* don't block response */
}
return result;
}
@@ -126,7 +128,9 @@ export class OccupanciesController {
content: `您已退宿房间 #${result.roomId}`,
});
}
} catch (_) { /* don't block response */ }
} catch (_) {
/* don't block response */
}
return result;
}
@@ -215,7 +219,7 @@ export class OccupanciesController {
gender: r.student?.gender || '',
phone: r.student?.phone || '',
idNumber: r.student?.idNumber || '',
organization: r.student?.organization || '',
organization: r.student?.organization?.name || '',
supervisor: r.student?.supervisor || '',
checkInDate: r.checkInDate || '',
checkOutDate: r.checkOutDate || '',

View File

@@ -6,13 +6,18 @@ import { Student } from '../entities/student.entity';
import { Deposit } from '../entities/deposit.entity';
import { Bed } from '../entities/bed.entity';
import { Locker } from '../entities/locker.entity';
import { Organization } from '../entities/organization.entity';
import { OccupanciesService } from './occupancies.service';
import { OccupanciesController } from './occupancies.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [TypeOrmModule.forFeature([Occupancy, Room, Student, Deposit, Bed, Locker]), OperationLogsModule, NotificationsModule],
imports: [
TypeOrmModule.forFeature([Occupancy, Room, Student, Deposit, Bed, Locker, Organization]),
OperationLogsModule,
NotificationsModule,
],
controllers: [OccupanciesController],
providers: [OccupanciesService],
exports: [OccupanciesService],

View File

@@ -0,0 +1,47 @@
import { Repository, DataSource } from 'typeorm';
import { OccupanciesService } from './occupancies.service';
import { Occupancy } from '../entities/occupancy.entity';
import { Room } from '../entities/room.entity';
import { Student } from '../entities/student.entity';
import { Deposit } from '../entities/deposit.entity';
import { Bed } from '../entities/bed.entity';
import { Locker } from '../entities/locker.entity';
describe('OccupanciesService — responsible organization', () => {
it('defaults the responsible organization to the student organization', async () => {
const occupancyRepo = {
findOne: jest.fn().mockResolvedValue(null),
count: jest.fn().mockResolvedValue(0),
create: jest.fn((value) => value),
save: jest.fn(async (value) => ({ ...value, id: 10 })),
} as any as Repository<Occupancy>;
const roomRepo = {
findOne: jest.fn().mockResolvedValue({ id: 2, capacity: 4, gender: null }),
update: jest.fn(),
} as any as Repository<Room>;
const studentRepo = {
findOne: jest.fn().mockResolvedValue({ id: 3, gender: '男', organizationId: 7 }),
} as any as Repository<Student>;
const service = new OccupanciesService(
occupancyRepo,
roomRepo,
studentRepo,
{} as Repository<Deposit>,
{} as Repository<Bed>,
{} as Repository<Locker>,
{} as Repository<any>,
{} as DataSource,
);
await service.checkIn({
studentId: 3,
roomId: 2,
checkInDate: '2026-07-10',
});
expect(occupancyRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ responsibleOrganizationId: 7 }),
);
});
});

View File

@@ -15,10 +15,11 @@ import { Student } from '../entities/student.entity';
import { Bed } from '../entities/bed.entity';
import { Locker } from '../entities/locker.entity';
import { Deposit } from '../entities/deposit.entity';
import { Organization } from '../entities/organization.entity';
import { uuidV7 } from '../common/uuid-v7';
import { CheckInDto, CheckOutDto, TransferRoomDto } from './dto/occupancy.dto';
import { RoomsService } from '../rooms/rooms.service';
@Injectable()
export class OccupanciesService {
constructor(
@@ -28,6 +29,7 @@ export class OccupanciesService {
@InjectRepository(Deposit) private depositRepo: Repository<Deposit>,
@InjectRepository(Bed) private bedRepo: Repository<Bed>,
@InjectRepository(Locker) private lockerRepo: Repository<Locker>,
@InjectRepository(Organization) private organizationRepo: Repository<Organization>,
private dataSource: DataSource,
) {}
@@ -38,6 +40,7 @@ export class OccupanciesService {
.leftJoinAndSelect('o.room', 'room')
.leftJoinAndSelect('o.bed', 'bed')
.leftJoinAndSelect('o.locker', 'locker')
.leftJoinAndSelect('o.responsibleOrganization', 'responsibleOrganization')
.orderBy('o.checkInDate', 'DESC');
if (query?.roomId) qb.andWhere('o.roomId = :roomId', { roomId: query.roomId });
if (query?.studentId) qb.andWhere('o.studentId = :studentId', { studentId: query.studentId });
@@ -76,7 +79,9 @@ export class OccupanciesService {
// 柜子校验
if (dto.lockerId) {
const locker = await this.lockerRepo.findOne({ where: { id: dto.lockerId, roomId: dto.roomId } });
const locker = await this.lockerRepo.findOne({
where: { id: dto.lockerId, roomId: dto.roomId },
});
if (!locker) throw new BadRequestException('柜子不存在或不属于该宿舍');
if (locker.status !== 'available') throw new BadRequestException('该柜子已被占用或维修中');
}
@@ -86,8 +91,8 @@ export class OccupanciesService {
roomId: dto.roomId,
checkInDate: dto.checkInDate,
billingStartDate: dto.billingStartDate || dto.checkInDate,
rentalType: dto.rentalType,
tenantId: dto.tenantId,
stayType: dto.stayType,
responsibleOrganizationId: dto.responsibleOrganizationId ?? student.organizationId,
notes: dto.notes,
bedId: dto.bedId,
lockerId: dto.lockerId,
@@ -192,12 +197,16 @@ export class OccupanciesService {
// 新床位校验
if (dto.newBedId) {
const newBed = await runner.manager.findOne(Bed, { where: { id: dto.newBedId, roomId: dto.newRoomId } });
const newBed = await runner.manager.findOne(Bed, {
where: { id: dto.newBedId, roomId: dto.newRoomId },
});
if (!newBed) throw new BadRequestException('目标床位不存在或不属于目标宿舍');
if (newBed.status !== 'available') throw new BadRequestException('目标床位已被占用');
}
if (dto.newLockerId) {
const newLocker = await runner.manager.findOne(Locker, { where: { id: dto.newLockerId, roomId: dto.newRoomId } });
const newLocker = await runner.manager.findOne(Locker, {
where: { id: dto.newLockerId, roomId: dto.newRoomId },
});
if (!newLocker) throw new BadRequestException('目标柜子不存在或不属于目标宿舍');
if (newLocker.status !== 'available') throw new BadRequestException('目标柜子已被占用');
}
@@ -214,8 +223,8 @@ export class OccupanciesService {
roomId: dto.newRoomId,
checkInDate: dto.transferDate,
billingStartDate: dto.newBillingStartDate || defaultBillingStart,
rentalType: oldOcc.rentalType,
tenantId: oldOcc.tenantId,
stayType: oldOcc.stayType,
responsibleOrganizationId: oldOcc.responsibleOrganizationId,
notes: `${oldOcc.roomId}号房换入`,
bedId: dto.newBedId,
lockerId: dto.newLockerId,
@@ -339,7 +348,8 @@ export class OccupanciesService {
}
// 释放床位/柜子
if (occ.bedId) await runner.manager.update(Bed, occ.bedId, { status: 'available' });
if (occ.lockerId) await runner.manager.update(Locker, occ.lockerId, { status: 'available' });
if (occ.lockerId)
await runner.manager.update(Locker, occ.lockerId, { status: 'available' });
success++;
}
await runner.commitTransaction();
@@ -395,7 +405,23 @@ export class OccupanciesService {
}
try {
// 1. 查找或创建学生
// 1. 解析所属机构;未填写时默认本机构
let organization = row.organization?.trim()
? await this.organizationRepo.findOne({ where: { name: row.organization.trim() } })
: await this.organizationRepo.findOne({ where: { isHost: true, status: 'active' } });
if (!organization && row.organization?.trim()) {
organization = await this.organizationRepo.save(
this.organizationRepo.create({
publicId: uuidV7(),
code: `ORG_${Date.now()}_${i}`,
name: row.organization.trim(),
isHost: false,
status: 'active',
}),
);
}
if (!organization) throw new BadRequestException('尚未配置本机构');
let student = await this.studentRepo.findOne({ where: { name: row.name.trim() } });
if (!student) {
student = await this.studentRepo.save(
@@ -407,7 +433,7 @@ export class OccupanciesService {
ethnicity: row.ethnicity?.trim() || undefined,
emergencyContact: row.emergencyContact?.trim() || undefined,
emergencyPhone: row.emergencyPhone?.trim() || undefined,
organization: row.organization?.trim() || undefined,
organizationId: organization.id,
supervisor: row.supervisor?.trim() || undefined,
}),
);
@@ -422,8 +448,7 @@ export class OccupanciesService {
updates.emergencyContact = row.emergencyContact.trim();
if (!student.emergencyPhone && row.emergencyPhone?.trim())
updates.emergencyPhone = row.emergencyPhone.trim();
if (!student.organization && row.organization?.trim())
updates.organization = row.organization.trim();
if (!student.organizationId) updates.organizationId = organization.id;
if (!student.supervisor && row.supervisor?.trim())
updates.supervisor = row.supervisor.trim();
if (Object.keys(updates).length > 0) {
@@ -486,6 +511,7 @@ export class OccupanciesService {
roomId: room.id,
checkInDate,
billingStartDate: checkInDate,
responsibleOrganizationId: student.organizationId || organization.id,
};
// 如果有退宿日期,直接记录
if (row.checkOutDate?.trim()) {

View File

@@ -1,13 +1,21 @@
import { IsOptional, IsString, IsNotEmpty, IsEnum } from 'class-validator';
import { IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString, Matches } from 'class-validator';
export class CreateTenantDto {
export class CreateOrganizationDto {
@IsString()
@IsNotEmpty()
name: string;
@IsString()
@Matches(/^[A-Z0-9_-]+$/)
code: string;
@IsOptional()
@IsBoolean()
isHost?: boolean;
@IsOptional()
@IsString()
contact?: string;
contactName?: string;
@IsOptional()
@IsString()
@@ -22,14 +30,19 @@ export class CreateTenantDto {
notes?: string;
}
export class UpdateTenantDto {
export class UpdateOrganizationDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
contact?: string;
@Matches(/^[A-Z0-9_-]+$/)
code?: string;
@IsOptional()
@IsString()
contactName?: string;
@IsOptional()
@IsString()
@@ -45,5 +58,5 @@ export class UpdateTenantDto {
@IsOptional()
@IsEnum(['active', 'archived'])
status?: string;
status?: 'active' | 'archived';
}

View File

@@ -10,45 +10,48 @@ import {
UseGuards,
Request,
} from '@nestjs/common';
import { TenantsService } from './tenants.service';
import { CreateTenantDto, UpdateTenantDto } from './dto/tenant.dto';
import { OrganizationsService } from './organizations.service';
import { CreateOrganizationDto, UpdateOrganizationDto } from './dto/organization.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils';
import { RequirePermission } from '../auth/decorators/permission.decorator';
@UseGuards(JwtAuthGuard)
@Controller('tenants')
export class TenantsController {
@Controller('organizations')
export class OrganizationsController {
constructor(
private service: TenantsService,
private service: OrganizationsService,
private logService: OperationLogsService,
) {}
@Get()
@RequirePermission('tenant:view')
findAll(@Query('includeArchived') includeArchived?: string) {
return this.service.findAll({ includeArchived: includeArchived === 'true' });
@RequirePermission('organization:view')
findAll(
@Query('includeArchived') includeArchived?: string,
@Query('scope') scope?: 'all' | 'host' | 'external',
) {
return this.service.findAll({ includeArchived: includeArchived === 'true', scope });
}
@Get(':id')
@RequirePermission('tenant:view')
@RequirePermission('organization:view')
findOne(@Param('id') id: string) {
return this.service.findOne(+id);
}
@Post()
@RequirePermission('tenant:create')
async create(@Body() dto: CreateTenantDto, @Request() req: any) {
@RequirePermission('organization:create')
async create(@Body() dto: CreateOrganizationDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.create(dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '租赁方',
action: '新增租赁方',
module: '机构管理',
action: '新增机构',
targetId: result.id,
targetType: 'tenant',
targetType: 'organization',
detail: dto.name,
ipAddress,
userAgent,
@@ -57,17 +60,17 @@ export class TenantsController {
}
@Put(':id')
@RequirePermission('tenant:edit')
async update(@Param('id') id: string, @Body() dto: UpdateTenantDto, @Request() req: any) {
@RequirePermission('organization:edit')
async update(@Param('id') id: string, @Body() dto: UpdateOrganizationDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.update(+id, dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '租赁方',
action: '编辑租赁方',
module: '机构管理',
action: '编辑机构',
targetId: +id,
targetType: 'tenant',
targetType: 'organization',
detail: JSON.stringify(dto),
ipAddress,
userAgent,
@@ -76,17 +79,17 @@ export class TenantsController {
}
@Delete(':id')
@RequirePermission('tenant:delete')
@RequirePermission('organization:delete')
async remove(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.remove(+id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '租赁方',
action: '归档租赁方',
module: '机构管理',
action: '归档机构',
targetId: +id,
targetType: 'tenant',
targetType: 'organization',
ipAddress,
userAgent,
});

View File

@@ -0,0 +1,30 @@
import { Module, OnModuleInit } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Organization } from '../entities/organization.entity';
import { OrganizationsService } from './organizations.service';
import { OrganizationsController } from './organizations.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
@Module({
imports: [TypeOrmModule.forFeature([Organization]), OperationLogsModule],
controllers: [OrganizationsController],
providers: [OrganizationsService],
exports: [OrganizationsService],
})
export class OrganizationsModule implements OnModuleInit {
constructor(private readonly service: OrganizationsService) {}
async onModuleInit() {
try {
await this.service.getHostOrganization();
} catch {
await this.service.create({
name: process.env.HOST_ORGANIZATION_NAME || '本机构',
code: process.env.HOST_ORGANIZATION_CODE || 'HOST',
isHost: true,
color: '#1677ff',
notes: '系统默认运营主体',
});
}
}
}

View File

@@ -0,0 +1,30 @@
import { BadRequestException } from '@nestjs/common';
import { Repository } from 'typeorm';
import { OrganizationsService } from './organizations.service';
import { Organization } from '../entities/organization.entity';
describe('OrganizationsService — host organization rules', () => {
let repo: jest.Mocked<
Pick<Repository<Organization>, 'findOne' | 'find' | 'count' | 'create' | 'save' | 'update'>
>;
let service: OrganizationsService;
beforeEach(() => {
repo = {
findOne: jest.fn(),
find: jest.fn(),
count: jest.fn(),
create: jest.fn(),
save: jest.fn(),
update: jest.fn(),
} as any;
service = new OrganizationsService(repo as Repository<Organization>);
});
it('does not allow the host organization to be archived', async () => {
repo.findOne.mockResolvedValue({ id: 1, name: '本机构', isHost: true } as Organization);
await expect(service.remove(1)).rejects.toBeInstanceOf(BadRequestException);
expect(repo.update).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,81 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Not, Repository } from 'typeorm';
import { uuidV7 } from '../common/uuid-v7';
import { Organization } from '../entities/organization.entity';
import { CreateOrganizationDto, UpdateOrganizationDto } from './dto/organization.dto';
const COLOR_PALETTE = [
'#ff7875',
'#ffa940',
'#ffc53d',
'#73d13d',
'#36cfc9',
'#40a9ff',
'#597ef7',
'#9254de',
'#f759ab',
'#8c8c8c',
];
@Injectable()
export class OrganizationsService {
constructor(@InjectRepository(Organization) private repo: Repository<Organization>) {}
async findAll(query?: { includeArchived?: boolean; scope?: 'all' | 'host' | 'external' }) {
const where: Record<string, unknown> = {};
if (!query?.includeArchived) where.status = Not('archived');
if (query?.scope === 'host') where.isHost = true;
if (query?.scope === 'external') where.isHost = false;
return this.repo.find({ where, order: { isHost: 'DESC', name: 'ASC' } });
}
async findOne(id: number) {
const organization = await this.repo.findOne({ where: { id } });
if (!organization) throw new NotFoundException('机构不存在');
return organization;
}
async getHostOrganization() {
const organization = await this.repo.findOne({ where: { isHost: true, status: 'active' } });
if (!organization) throw new NotFoundException('尚未配置本机构');
return organization;
}
async create(dto: CreateOrganizationDto) {
if (dto.isHost) {
const existingHost = await this.repo.findOne({ where: { isHost: true } });
if (existingHost) throw new BadRequestException('本机构已存在,只能配置一个本机构');
}
const color = dto.color || COLOR_PALETTE[(await this.repo.count()) % COLOR_PALETTE.length];
return this.repo.save(
this.repo.create({
...dto,
code: dto.code.trim().toUpperCase(),
publicId: uuidV7(),
color,
isHost: dto.isHost ?? false,
status: 'active',
}),
);
}
async update(id: number, dto: UpdateOrganizationDto) {
const organization = await this.findOne(id);
if (organization.isHost && dto.status === 'archived') {
throw new BadRequestException('本机构不能归档');
}
await this.repo.update(id, {
...dto,
...(dto.code ? { code: dto.code.trim().toUpperCase() } : {}),
});
return this.repo.findOne({ where: { id } });
}
async remove(id: number) {
const organization = await this.findOne(id);
if (organization.isHost) throw new BadRequestException('本机构不能归档');
await this.repo.update(id, { status: 'archived' });
return { message: '已归档' };
}
}

View File

@@ -51,10 +51,10 @@ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> =
{ code: 'classroom:create', name: '新增教室', group: 'classroom' },
{ code: 'classroom:edit', name: '编辑教室', group: 'classroom' },
{ code: 'classroom:delete', name: '删除教室', group: 'classroom' },
{ code: 'tenant:view', name: '查看租赁方', group: 'tenant' },
{ code: 'tenant:create', name: '新增租赁方', group: 'tenant' },
{ code: 'tenant:edit', name: '编辑租赁方', group: 'tenant' },
{ code: 'tenant:delete', name: '删除租赁方', group: 'tenant' },
{ code: 'organization:view', name: '查看机构', group: 'organization' },
{ code: 'organization:create', name: '新增机构', group: 'organization' },
{ code: 'organization:edit', name: '编辑机构', group: 'organization' },
{ code: 'organization:delete', name: '归档机构', group: 'organization' },
{ code: 'rental:view', name: '查看租赁订单', group: 'rental' },
{ code: 'rental:create', name: '新增租赁订单', group: 'rental' },
{ code: 'rental:edit', name: '编辑租赁订单', group: 'rental' },
@@ -154,7 +154,7 @@ export const PRESET_ROLES: Array<{
code: 'institution_head',
description: '管理机构教室和课程',
isSystem: true,
permissionGroups: ['classroom', 'rental', 'tenant', 'notification', 'profile'],
permissionGroups: ['classroom', 'rental', 'organization', 'notification', 'profile'],
},
{
name: '财务',

View File

@@ -191,7 +191,7 @@ export class RoomsService {
{ checkInDate: LessThanOrEqual(targetDate), checkOutDate: MoreThanOrEqual(targetDate) },
]
: { checkOutDate: IsNull() },
relations: ['student', 'tenant'],
relations: ['student', 'student.organization', 'responsibleOrganization'],
order: { checkInDate: 'ASC' },
});
@@ -212,11 +212,11 @@ export class RoomsService {
checkInDate: occ.checkInDate,
billingStartDate: occ.billingStartDate,
days,
organization: occ.student?.organization || null,
organization: occ.student?.organization?.name || null,
supervisor: occ.student?.supervisor || null,
tenantId: occ.tenantId || null,
tenantName: occ.tenant?.name || null,
tenantColor: occ.tenant?.color || null,
organizationId: occ.responsibleOrganizationId || null,
organizationName: occ.responsibleOrganization?.name || null,
organizationColor: occ.responsibleOrganization?.color || null,
});
}
@@ -250,9 +250,9 @@ export class RoomsService {
const allSameOrg = occ.every((o: any) => o.organization && o.organization === orgs[0]);
orgLabel = allSameOrg ? `均为${orgs[0]}人员` : `存在${orgs.join('、')}人员`;
}
const tenantColors = [...new Set(occ.map((o: any) => o.tenantColor).filter(Boolean))];
const tenantColor: string | null = tenantColors.length === 1 ? tenantColors[0] : null;
const tenantIds = [...new Set(occ.map((o: any) => o.tenantId).filter(Boolean))];
const organizationColors = [...new Set(occ.map((o: any) => o.organizationColor).filter(Boolean))];
const organizationColor: string | null = organizationColors.length === 1 ? organizationColors[0] : null;
const organizationIds = [...new Set(occ.map((o: any) => o.organizationId).filter(Boolean))];
return {
id: room.id,
roomNumber: room.roomNumber,
@@ -265,16 +265,16 @@ export class RoomsService {
occupiedBeds: bedMap.get(room.id)?.occupied ?? 0,
occupants: occ,
orgLabel,
tenantColor,
tenantIds,
organizationColor,
organizationIds,
};
}),
// 当前视图内出现过的租赁方,供筛选下拉使用
tenants: [
// 当前视图内出现过的负责机构,供筛选下拉使用
organizations: [
...new Map(
occupancies
.filter((o) => o.tenantId && o.tenant)
.map((o) => [o.tenantId, { id: o.tenantId, name: o.tenant.name, color: o.tenant.color || null }]),
.filter((o) => o.responsibleOrganizationId && o.responsibleOrganization)
.map((o) => [o.responsibleOrganizationId, { id: o.responsibleOrganizationId, name: o.responsibleOrganization.name, color: o.responsibleOrganization.color || null }]),
).values(),
].sort((a, b) => a.name.localeCompare(b.name)),
};

View File

@@ -172,7 +172,7 @@ describe('SchedulesService — getClassroomOccupancy', () => {
const rentalSchedule = {
id: 2,
scheduleType: ScheduleType.RENTAL,
subject: 'Tenant A 租赁',
subject: 'Organization A 租赁',
} as ClassSchedule;
const qb = mockQueryBuilder<ClassSchedule>([internalSchedule, rentalSchedule]);
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb);

View File

@@ -1,4 +1,4 @@
import { IsString, IsOptional, IsEnum, IsNumber, IsInt } from 'class-validator';
import { IsString, IsOptional, IsEnum, IsInt } from 'class-validator';
export class CreateStudentDto {
@IsString()
@@ -32,19 +32,12 @@ export class CreateStudentDto {
@IsString()
emergencyPhone?: string;
@IsOptional()
@IsString()
organization?: string;
@IsOptional()
@IsNumber()
tenantId?: number;
@IsInt()
organizationId: number;
@IsOptional()
@IsString()
supervisor?: string;
@IsOptional()
@IsOptional()
@IsInt()
classId?: number;
@@ -84,12 +77,8 @@ export class UpdateStudentDto {
emergencyPhone?: string;
@IsOptional()
@IsString()
organization?: string;
@IsOptional()
@IsNumber()
tenantId?: number;
@IsInt()
organizationId?: number;
@IsOptional()
@IsString()

View File

@@ -16,7 +16,7 @@ import {
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Tenant } from '../entities/tenant.entity';
import { Organization } from '../entities/organization.entity';
import { ClassTeacher } from '../entities/class-teacher.entity';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
@@ -34,7 +34,7 @@ export class StudentsController {
constructor(
private service: StudentsService,
private logService: OperationLogsService,
@InjectRepository(Tenant) private tenantRepo: Repository<Tenant>,
@InjectRepository(Organization) private organizationRepo: Repository<Organization>,
) {}
private canManageAllStudents(user: { isSuperAdmin?: boolean; permissions?: string[] }): boolean {
@@ -51,7 +51,7 @@ export class StudentsController {
@Query('name') name: string | undefined,
@Query('status') status: string | undefined,
@Query('includeArchived') includeArchived: string | undefined,
@Query('tenantId') tenantId: string | undefined,
@Query('organizationId') organizationId: string | undefined,
@Request() req: { user: { id: number; isSuperAdmin?: boolean; permissions?: string[] } },
) {
const classIds = await this.service.getAccessibleClassIds(
@@ -63,7 +63,7 @@ export class StudentsController {
name,
status,
includeArchived: includeArchived === 'true',
tenantId: tenantId ? +tenantId : undefined,
organizationId: organizationId ? +organizationId : undefined,
},
classIds,
);
@@ -94,7 +94,7 @@ export class StudentsController {
{ header: '民族', key: 'ethnicity', width: 10 },
{ header: '紧急联系人', key: 'emergencyContact', width: 15 },
{ header: '紧急联系人电话', key: 'emergencyPhone', width: 18 },
{ header: '所属机构', key: 'tenant', width: 18 },
{ header: '所属机构', key: 'organization', width: 18 },
{ header: '负责人/班主任', key: 'supervisor', width: 15 },
{ header: '状态', key: 'status', width: 10 },
];
@@ -115,7 +115,7 @@ export class StudentsController {
ethnicity: s.ethnicity || '',
emergencyContact: s.emergencyContact || '',
emergencyPhone: s.emergencyPhone || '',
tenant: s.tenant?.name || '',
organization: s.organization?.name || '',
supervisor: s.supervisor || '',
status: statusMap[s.status] || s.status,
});
@@ -152,7 +152,7 @@ export class StudentsController {
{ header: '民族', key: 'ethnicity', width: 10 },
{ header: '紧急联系人', key: 'emergencyContact', width: 15 },
{ header: '紧急联系人电话', key: 'emergencyPhone', width: 18 },
{ header: '所属机构(租赁方名称', key: 'tenant', width: 18 },
{ header: '所属机构名称', key: 'organization', width: 18 },
{ header: '负责人/班主任', key: 'supervisor', width: 15 },
];
ws.getRow(1).font = { bold: true };
@@ -165,7 +165,7 @@ export class StudentsController {
ethnicity: '汉族',
emergencyContact: '张父',
emergencyPhone: '13900000000',
tenant: 'XX教育公司',
organization: 'XX教育公司',
supervisor: '',
});
res.setHeader(
@@ -290,9 +290,9 @@ export class StudentsController {
ethnicity?: string;
emergencyContact?: string;
emergencyPhone?: string;
tenant?: string;
organization?: string;
supervisor?: string;
tenantId?: number;
organizationId?: number;
}[] = [];
ws.eachRow((row, idx) => {
if (idx === 1) return;
@@ -304,16 +304,18 @@ export class StudentsController {
ethnicity: String(row.getCell(5).value || '').trim() || undefined,
emergencyContact: String(row.getCell(6).value || '').trim() || undefined,
emergencyPhone: String(row.getCell(7).value || '').trim() || undefined,
tenant: String(row.getCell(8).value || '').trim() || undefined,
organization: String(row.getCell(8).value || '').trim() || undefined,
supervisor: String(row.getCell(9).value || '').trim() || undefined,
});
});
// Resolve tenant names to IDs
// Resolve organization names to IDs
for (const row of rows) {
if (row.tenant) {
const tenant = await this.tenantRepo.findOne({ where: { name: row.tenant } });
if (tenant) {
row.tenantId = tenant.id;
if (row.organization) {
const organization = await this.organizationRepo.findOne({
where: { name: row.organization },
});
if (organization) {
row.organizationId = organization.id;
}
}
}
@@ -348,7 +350,7 @@ export class StudentsController {
emergencyPhone?: string;
organization?: string;
supervisor?: string;
tenantId?: number;
organizationId?: number;
}[] = [];
ws.eachRow((row, idx) => {
if (idx === 1) return;
@@ -364,11 +366,13 @@ export class StudentsController {
supervisor: String(row.getCell(9).value || '').trim() || undefined,
});
});
// Resolve tenant names to IDs
// Resolve organization names to IDs
for (const row of rows) {
if (row.organization) {
const tenant = await this.tenantRepo.findOne({ where: { name: row.organization } });
if (tenant) row.tenantId = tenant.id;
const organization = await this.organizationRepo.findOne({
where: { name: row.organization },
});
if (organization) row.organizationId = organization.id;
}
}
const result = await this.service.matchImport(rows);

View File

@@ -2,7 +2,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Student } from '../entities/student.entity';
import { Class } from '../entities/class.entity';
import { Tenant } from '../entities/tenant.entity';
import { Organization } from '../entities/organization.entity';
import { ClassStudent } from '../entities/class-student.entity';
import { AttendanceRecord } from '../entities/attendance-record.entity';
import { ClassTeacher } from '../entities/class-teacher.entity';
@@ -16,7 +16,7 @@ import { StudentsController } from './students.controller';
Class,
ClassStudent,
AttendanceRecord,
Tenant,
Organization,
ClassTeacher,
]),
],

View File

@@ -12,6 +12,7 @@ describe('StudentsService — teacher class scope', () => {
{} as never,
{} as never,
{} as never,
{} as never,
);
await service.findAll({}, [3, 5]);
@@ -22,7 +23,7 @@ describe('StudentsService — teacher class scope', () => {
expect(repo.find).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ id: expect.any(Object) }),
relations: ['tenant'],
relations: ['organization'],
}),
);
});
@@ -35,6 +36,7 @@ describe('StudentsService — teacher class scope', () => {
{} as never,
{} as never,
{} as never,
{} as never,
);
await expect(service.findAll({}, [])).resolves.toEqual([]);

View File

@@ -6,6 +6,7 @@ import { Class } from '../entities/class.entity';
import { ClassStudent } from '../entities/class-student.entity';
import { ClassTeacher } from '../entities/class-teacher.entity';
import { AttendanceRecord } from '../entities/attendance-record.entity';
import { Organization } from '../entities/organization.entity';
import { CreateStudentDto, UpdateStudentDto } from './dto/student.dto';
@Injectable()
@@ -16,6 +17,7 @@ export class StudentsService {
@InjectRepository(Class) private classRepo: Repository<Class>,
@InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
@InjectRepository(Organization) private organizationRepo: Repository<Organization>,
) {}
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
@@ -29,13 +31,13 @@ export class StudentsService {
name?: string;
status?: string;
includeArchived?: boolean;
tenantId?: number | string;
organizationId?: number | string;
},
accessibleClassIds?: number[],
) {
const where: FindOptionsWhere<Student> = {};
if (query?.name) where.name = Like(`%${query.name}%`);
if (query?.tenantId) where.tenantId = Number(query.tenantId);
if (query?.organizationId) where.organizationId = Number(query.organizationId);
if (query?.status) {
where.status = query.status;
} else if (!query?.includeArchived) {
@@ -50,7 +52,7 @@ export class StudentsService {
if (studentIds.length === 0) return [];
where.id = In(studentIds);
}
return this.repo.find({ where, order: { createdAt: 'DESC' }, relations: ['tenant'] });
return this.repo.find({ where, order: { createdAt: 'DESC' }, relations: ['organization'] });
}
async findOne(id: number) {
@@ -63,11 +65,13 @@ export class StudentsService {
}
async create(dto: CreateStudentDto) {
await this.assertActiveOrganization(dto.organizationId);
return this.repo.save(this.repo.create(dto));
}
async update(id: number, dto: UpdateStudentDto) {
await this.findOne(id);
if (dto.organizationId) await this.assertActiveOrganization(dto.organizationId);
await this.repo.update(id, dto);
return this.repo.findOne({ where: { id } });
}
@@ -127,7 +131,7 @@ export class StudentsService {
emergencyPhone?: string;
organization?: string;
supervisor?: string;
tenantId?: number;
organizationId?: number;
}[],
) {
let imported = 0;
@@ -151,9 +155,8 @@ export class StudentsService {
ethnicity: row.ethnicity || undefined,
emergencyContact: row.emergencyContact || undefined,
emergencyPhone: row.emergencyPhone || undefined,
organization: row.organization || undefined,
supervisor: row.supervisor || undefined,
tenantId: row.tenantId || undefined,
organizationId: row.organizationId || (await this.getHostOrganizationId()),
}),
);
imported++;
@@ -176,7 +179,7 @@ export class StudentsService {
emergencyPhone?: string;
organization?: string;
supervisor?: string;
tenantId?: number;
organizationId?: number;
}[],
) {
let matched = 0;
@@ -208,9 +211,8 @@ export class StudentsService {
| 'ethnicity'
| 'emergencyContact'
| 'emergencyPhone'
| 'organization'
| 'supervisor'
| 'tenantId'
| 'organizationId'
>
> = {};
if (row.name?.trim()) updates.name = row.name.trim();
@@ -220,9 +222,8 @@ export class StudentsService {
if (row.ethnicity) updates.ethnicity = row.ethnicity;
if (row.emergencyContact) updates.emergencyContact = row.emergencyContact;
if (row.emergencyPhone) updates.emergencyPhone = row.emergencyPhone;
if (row.organization) updates.organization = row.organization;
if (row.supervisor) updates.supervisor = row.supervisor;
if (row.tenantId) updates.tenantId = row.tenantId;
if (row.organizationId) updates.organizationId = row.organizationId;
await this.repo.update(student.id, updates as Partial<Student>);
matched++;
}
@@ -233,6 +234,19 @@ export class StudentsService {
};
}
private async assertActiveOrganization(id: number) {
const organization = await this.organizationRepo.findOne({ where: { id, status: 'active' } });
if (!organization) throw new BadRequestException('所属机构不存在或已归档');
}
private async getHostOrganizationId() {
const organization = await this.organizationRepo.findOne({
where: { isHost: true, status: 'active' },
});
if (!organization) throw new BadRequestException('尚未配置本机构');
return organization.id;
}
async compareClasses(studentId: number) {
const student = await this.repo.findOne({ where: { id: studentId } });
if (!student) throw new NotFoundException('学生不存在');

View File

@@ -1,14 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Tenant } from '../entities/tenant.entity';
import { TenantsService } from './tenants.service';
import { TenantsController } from './tenants.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
@Module({
imports: [TypeOrmModule.forFeature([Tenant]), OperationLogsModule],
controllers: [TenantsController],
providers: [TenantsService],
exports: [TenantsService],
})
export class TenantsModule {}

View File

@@ -1,58 +0,0 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Not } from 'typeorm';
import { Tenant } from '../entities/tenant.entity';
import { CreateTenantDto, UpdateTenantDto } from './dto/tenant.dto';
// 预设色板(避开红绿盲敏感色,保证差异度)
const COLOR_PALETTE = [
'#ff7875',
'#ffa940',
'#ffc53d',
'#73d13d',
'#36cfc9',
'#40a9ff',
'#597ef7',
'#9254de',
'#f759ab',
'#8c8c8c',
];
@Injectable()
export class TenantsService {
constructor(@InjectRepository(Tenant) private repo: Repository<Tenant>) {}
async findAll(query?: { includeArchived?: boolean }) {
const where: any = {};
if (!query?.includeArchived) where.status = Not('archived');
return this.repo.find({ where, order: { createdAt: 'DESC' } });
}
async findOne(id: number) {
const tenant = await this.repo.findOne({ where: { id } });
if (!tenant) throw new NotFoundException('租赁方不存在');
return tenant;
}
async create(dto: CreateTenantDto) {
// 颜色未指定则自动分配(按当前租赁方数量取模)
let color = dto.color;
if (!color) {
const total = await this.repo.count();
color = COLOR_PALETTE[total % COLOR_PALETTE.length];
}
return this.repo.save(this.repo.create({ ...dto, color }));
}
async update(id: number, dto: UpdateTenantDto) {
await this.findOne(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' });
return { message: '已归档' };
}
}