forked from wangziqi/gongxue-base
feat: replace tenants with organization management
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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_schedules(schedule_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,
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
11
apps/server/src/common/uuid-v7.spec.ts
Normal file
11
apps/server/src/common/uuid-v7.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
21
apps/server/src/common/uuid-v7.ts
Normal file
21
apps/server/src/common/uuid-v7.ts
Normal 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)}`;
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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[];
|
||||
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 || '',
|
||||
|
||||
@@ -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],
|
||||
|
||||
47
apps/server/src/occupancies/occupancies.service.spec.ts
Normal file
47
apps/server/src/occupancies/occupancies.service.spec.ts
Normal 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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
30
apps/server/src/organizations/organizations.module.ts
Normal file
30
apps/server/src/organizations/organizations.module.ts
Normal 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: '系统默认运营主体',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
30
apps/server/src/organizations/organizations.service.spec.ts
Normal file
30
apps/server/src/organizations/organizations.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
81
apps/server/src/organizations/organizations.service.ts
Normal file
81
apps/server/src/organizations/organizations.service.ts
Normal 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: '已归档' };
|
||||
}
|
||||
}
|
||||
@@ -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: '财务',
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
]),
|
||||
],
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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('学生不存在');
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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: '已归档' };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user