test: harden business boundary conditions
This commit is contained in:
102
apps/server/src/archive/archive.boundaries.spec.ts
Normal file
102
apps/server/src/archive/archive.boundaries.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { ArchiveService } from './archive.service';
|
||||
|
||||
function createService(repos: Partial<Record<string, Record<string, jest.Mock>>> = {}) {
|
||||
return new ArchiveService(
|
||||
(repos.student ?? {}) as never,
|
||||
(repos.profile ?? {}) as never,
|
||||
(repos.enrollment ?? {}) as never,
|
||||
(repos.exam ?? {}) as never,
|
||||
(repos.learning ?? {}) as never,
|
||||
(repos.result ?? {}) as never,
|
||||
(repos.attachment ?? {}) as never,
|
||||
(repos.attendance ?? {}) as never,
|
||||
{} as never,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ArchiveService — resource and relationship boundaries', () => {
|
||||
it('rejects adding archive records for a missing student', async () => {
|
||||
const student = { findOne: jest.fn().mockResolvedValue(null) };
|
||||
const service = createService({ student });
|
||||
|
||||
await expect(
|
||||
service.addEnrollment(404, { courseCategory: '文化', classType: '冲刺' }),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(
|
||||
service.addLearningRecord(404, {
|
||||
recordDate: '2026-07-14',
|
||||
recordType: '沟通',
|
||||
content: '内容',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('rejects linking an exam score to another student enrollment', async () => {
|
||||
const exam = { create: jest.fn(), save: jest.fn() };
|
||||
const service = createService({
|
||||
student: { findOne: jest.fn().mockResolvedValue({ id: 7 }) },
|
||||
enrollment: { findOne: jest.fn().mockResolvedValue(null) },
|
||||
exam,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.addExamScore(7, {
|
||||
examType: '月考',
|
||||
subject: '语文',
|
||||
score: 90,
|
||||
enrollmentId: 99,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(exam.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects moving an existing exam score to another student enrollment', async () => {
|
||||
const exam = {
|
||||
findOne: jest.fn().mockResolvedValue({ id: 3, studentId: 7, enrollmentId: 1 }),
|
||||
save: jest.fn(),
|
||||
};
|
||||
const service = createService({
|
||||
enrollment: { findOne: jest.fn().mockResolvedValue(null) },
|
||||
exam,
|
||||
});
|
||||
|
||||
await expect(service.updateExamScore(3, { enrollmentId: 99 })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(exam.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a missing attachment upload before writing to disk', async () => {
|
||||
const service = createService({ student: { findOne: jest.fn() } });
|
||||
await expect(service.addAttachment(7, undefined as never, 'other')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects attachment path traversal', async () => {
|
||||
const service = createService({
|
||||
attachment: {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
studentId: 7,
|
||||
filePath: '../../etc/passwd',
|
||||
}),
|
||||
},
|
||||
});
|
||||
await expect(service.getAttachmentFile(7, 1)).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('returns not found for update/delete of absent child records', async () => {
|
||||
const service = createService({
|
||||
enrollment: { findOne: jest.fn().mockResolvedValue(null) },
|
||||
exam: { findOne: jest.fn().mockResolvedValue(null) },
|
||||
learning: { findOne: jest.fn().mockResolvedValue(null) },
|
||||
attachment: { findOne: jest.fn().mockResolvedValue(null) },
|
||||
});
|
||||
await expect(service.updateEnrollment(1, {})).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(service.deleteExamScore(1)).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(service.deleteLearningRecord(1)).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(service.deleteAttachment(1)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
@@ -61,20 +61,27 @@ export class ArchiveService {
|
||||
const student = await this.studentRepo.findOne({ where: { id: studentId } });
|
||||
if (!student) throw new NotFoundException('学生不存在');
|
||||
|
||||
const [profileRaw, enrollments, examScores, learningRecords, resultArchive, attachments, attendances] =
|
||||
await Promise.all([
|
||||
this.profileRepo.findOne({ where: { studentId } }),
|
||||
this.enrollmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
|
||||
this.examScoreRepo.find({ where: { studentId }, order: { examDate: 'DESC' } }),
|
||||
this.learningRecordRepo.find({ where: { studentId }, order: { recordDate: 'DESC' } }),
|
||||
this.resultRepo.findOne({ where: { studentId } }),
|
||||
this.attachmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
|
||||
this.attendanceRepo.find({
|
||||
where: { studentId },
|
||||
relations: ['schedule', 'class'],
|
||||
order: { attendanceDate: 'DESC', punchTime: 'DESC' },
|
||||
}),
|
||||
]);
|
||||
const [
|
||||
profileRaw,
|
||||
enrollments,
|
||||
examScores,
|
||||
learningRecords,
|
||||
resultArchive,
|
||||
attachments,
|
||||
attendances,
|
||||
] = await Promise.all([
|
||||
this.profileRepo.findOne({ where: { studentId } }),
|
||||
this.enrollmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
|
||||
this.examScoreRepo.find({ where: { studentId }, order: { examDate: 'DESC' } }),
|
||||
this.learningRecordRepo.find({ where: { studentId }, order: { recordDate: 'DESC' } }),
|
||||
this.resultRepo.findOne({ where: { studentId } }),
|
||||
this.attachmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
|
||||
this.attendanceRepo.find({
|
||||
where: { studentId },
|
||||
relations: ['schedule', 'class'],
|
||||
order: { attendanceDate: 'DESC', punchTime: 'DESC' },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
student,
|
||||
@@ -123,9 +130,18 @@ export class ArchiveService {
|
||||
return { message: '已删除' };
|
||||
}
|
||||
|
||||
private async assertEnrollmentBelongsToStudent(studentId: number, enrollmentId?: number) {
|
||||
if (enrollmentId === undefined) return;
|
||||
const enrollment = await this.enrollmentRepo.findOne({
|
||||
where: { id: enrollmentId, studentId },
|
||||
});
|
||||
if (!enrollment) throw new BadRequestException('报名记录不属于该学生');
|
||||
}
|
||||
|
||||
async addExamScore(studentId: number, dto: CreateExamScoreDto) {
|
||||
const student = await this.studentRepo.findOne({ where: { id: studentId } });
|
||||
if (!student) throw new NotFoundException('学生不存在');
|
||||
await this.assertEnrollmentBelongsToStudent(studentId, dto.enrollmentId);
|
||||
|
||||
const entity = this.examScoreRepo.create({ ...dto, studentId });
|
||||
return this.examScoreRepo.save(entity);
|
||||
@@ -134,6 +150,7 @@ export class ArchiveService {
|
||||
async updateExamScore(id: number, dto: UpdateExamScoreDto) {
|
||||
const entity = await this.examScoreRepo.findOne({ where: { id } });
|
||||
if (!entity) throw new NotFoundException('考试成绩不存在');
|
||||
await this.assertEnrollmentBelongsToStudent(entity.studentId, dto.enrollmentId);
|
||||
Object.assign(entity, dto);
|
||||
return this.examScoreRepo.save(entity);
|
||||
}
|
||||
@@ -181,6 +198,8 @@ export class ArchiveService {
|
||||
}
|
||||
|
||||
async addAttachment(studentId: number, file: Express.Multer.File, category: string) {
|
||||
if (!file?.buffer || !file.originalname) throw new BadRequestException('请选择附件文件');
|
||||
|
||||
const student = await this.studentRepo.findOne({ where: { id: studentId } });
|
||||
if (!student) throw new NotFoundException('学生不存在');
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { IsOptional, IsString, IsNumber, IsDateString } from 'class-validator';
|
||||
import { IsOptional, IsString, IsNumber, IsDateString, IsNotEmpty, Min } from 'class-validator';
|
||||
|
||||
export class UpsertProfileDto {
|
||||
@IsOptional() @IsString() targetCollege?: string;
|
||||
@@ -11,8 +11,8 @@ export class UpsertProfileDto {
|
||||
}
|
||||
|
||||
export class CreateEnrollmentDto {
|
||||
@IsString() courseCategory: string;
|
||||
@IsString() classType: string;
|
||||
@IsString() @IsNotEmpty() courseCategory: string;
|
||||
@IsString() @IsNotEmpty() classType: string;
|
||||
@IsOptional() @IsString() className?: string;
|
||||
@IsOptional() @IsString() headTeacher?: string;
|
||||
@IsOptional() @IsString() subjectTeacher?: string;
|
||||
@@ -24,12 +24,12 @@ export class CreateEnrollmentDto {
|
||||
export class UpdateEnrollmentDto extends PartialType(CreateEnrollmentDto) {}
|
||||
|
||||
export class CreateExamScoreDto {
|
||||
@IsString() examType: string;
|
||||
@IsString() @IsNotEmpty() examType: string;
|
||||
@IsOptional() @IsString() examName?: string;
|
||||
@IsString() subject: string;
|
||||
@IsNumber() score: number;
|
||||
@IsOptional() @IsNumber() classAvg?: number;
|
||||
@IsOptional() @IsNumber() rank?: number;
|
||||
@IsString() @IsNotEmpty() subject: string;
|
||||
@IsNumber() @Min(0) score: number;
|
||||
@IsOptional() @IsNumber() @Min(0) classAvg?: number;
|
||||
@IsOptional() @IsNumber() @Min(1) rank?: number;
|
||||
@IsOptional() @IsDateString() examDate?: string;
|
||||
@IsOptional() @IsNumber() enrollmentId?: number;
|
||||
}
|
||||
@@ -38,8 +38,8 @@ export class UpdateExamScoreDto extends PartialType(CreateExamScoreDto) {}
|
||||
|
||||
export class CreateLearningRecordDto {
|
||||
@IsDateString() recordDate: string;
|
||||
@IsString() recordType: string;
|
||||
@IsString() content: string;
|
||||
@IsString() @IsNotEmpty() recordType: string;
|
||||
@IsString() @IsNotEmpty() content: string;
|
||||
@IsOptional() @IsString() followUpMethod?: string;
|
||||
@IsOptional() @IsString() nextStep?: string;
|
||||
}
|
||||
@@ -47,8 +47,8 @@ export class CreateLearningRecordDto {
|
||||
export class UpdateLearningRecordDto extends PartialType(CreateLearningRecordDto) {}
|
||||
|
||||
export class UpsertResultDto {
|
||||
@IsOptional() @IsNumber() cultureFinalScore?: number;
|
||||
@IsOptional() @IsNumber() professionalFinalScore?: number;
|
||||
@IsOptional() @IsNumber() @Min(0) cultureFinalScore?: number;
|
||||
@IsOptional() @IsNumber() @Min(0) professionalFinalScore?: number;
|
||||
@IsOptional() @IsString() admissionStatus?: string;
|
||||
@IsOptional() @IsString() admittedCollege?: string;
|
||||
@IsOptional() @IsString() admittedMajor?: string;
|
||||
|
||||
@@ -593,3 +593,38 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
||||
expect(result.source).toBe('manual');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AttendanceService — attendance window boundaries', () => {
|
||||
it('crosses calendar boundaries only when the window requires it', () => {
|
||||
const { service } = createService();
|
||||
expect(service.getLessonAttendanceImportDateRange(
|
||||
{ startTime: '00:30', endTime: '01:30', attendanceAdvanceMinutes: 30 }, '2026-07-13',
|
||||
)).toEqual({ startDate: '2026-07-13', endDate: '2026-07-13' });
|
||||
expect(service.getLessonAttendanceImportDateRange(
|
||||
{ startTime: '00:30', endTime: '01:30', attendanceAdvanceMinutes: 31 }, '2026-07-13',
|
||||
)).toEqual({ startDate: '2026-07-12', endDate: '2026-07-13' });
|
||||
expect(service.getLessonAttendanceImportDateRange(
|
||||
{ startTime: '22:00', endTime: '01:00', attendanceAdvanceMinutes: 30 }, '2026-07-13',
|
||||
)).toEqual({ startDate: '2026-07-13', endDate: '2026-07-14' });
|
||||
});
|
||||
|
||||
it('uses Asia/Shanghai time when deciding whether todays lesson has started', async () => {
|
||||
const originalTz = process.env.TZ;
|
||||
process.env.TZ = 'UTC';
|
||||
jest.useFakeTimers().setSystemTime(new Date('2026-07-13T01:00:00.000Z'));
|
||||
try {
|
||||
const { service, scheduleRepo, sessionRepo, attendanceRepo } = createService();
|
||||
scheduleRepo.findOne.mockResolvedValue({
|
||||
...endedSchedule, weekDay: 1, startTime: '08:30', endTime: '10:00',
|
||||
startDate: '2026-07-13', endDate: '2026-07-13',
|
||||
});
|
||||
sessionRepo.findOne.mockResolvedValue({ id: 90, status: 'completed' });
|
||||
attendanceRepo.find.mockResolvedValue([]);
|
||||
await expect(service.createLessonAttendanceFromDingTalk(4, '2026-07-13', 21))
|
||||
.resolves.toMatchObject({ records: [] });
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
process.env.TZ = originalTz;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -276,16 +276,13 @@ export class AttendanceService {
|
||||
) {
|
||||
const schedule = await this.getScheduleOccurrence(scheduleId, lessonDate);
|
||||
const now = new Date();
|
||||
const today = [
|
||||
now.getFullYear(),
|
||||
String(now.getMonth() + 1).padStart(2, '0'),
|
||||
String(now.getDate()).padStart(2, '0'),
|
||||
].join('-');
|
||||
const courseClock = this.getCourseClock(now);
|
||||
const today = courseClock.date;
|
||||
if (lessonDate > today) throw new BadRequestException('课程尚未开始,不能拉取考勤');
|
||||
if (lessonDate === today) {
|
||||
const [hour, minute] = schedule.startTime.split(':').map(Number);
|
||||
const startMinute = hour * 60 + minute;
|
||||
const currentMinute = now.getHours() * 60 + now.getMinutes();
|
||||
const currentMinute = courseClock.minutes;
|
||||
if (currentMinute < startMinute) {
|
||||
throw new BadRequestException('课程尚未开始,不能拉取考勤');
|
||||
}
|
||||
@@ -676,6 +673,20 @@ export class AttendanceService {
|
||||
return hour * 60 + minute;
|
||||
}
|
||||
|
||||
private getCourseClock(date: Date): { date: string; minutes: number } {
|
||||
const parts = Object.fromEntries(
|
||||
new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
|
||||
}).formatToParts(date).filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]),
|
||||
);
|
||||
return {
|
||||
date: `${parts.year}-${parts.month}-${parts.day}`,
|
||||
minutes: Number(parts.hour) * 60 + Number(parts.minute),
|
||||
};
|
||||
}
|
||||
|
||||
private shiftDate(date: string, days: number): string {
|
||||
const shifted = new Date(`${date}T00:00:00.000Z`);
|
||||
shifted.setUTCDate(shifted.getUTCDate() + days);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
describe('AuthService — super admin identity', () => {
|
||||
describe('AuthService — authentication boundaries', () => {
|
||||
it('marks the preset 超管 role as super admin in the JWT payload', async () => {
|
||||
const userRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
@@ -22,8 +22,29 @@ describe('AuthService — super admin identity', () => {
|
||||
|
||||
await service.login({ username: 'admin', password: 'secret' }, '127.0.0.1');
|
||||
|
||||
expect(jwtService.sign).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isSuperAdmin: true }),
|
||||
expect(jwtService.sign).toHaveBeenCalledWith(expect.objectContaining({ isSuperAdmin: true }));
|
||||
});
|
||||
it('rejects an archived user even when the password is valid', async () => {
|
||||
const userRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: 2,
|
||||
username: 'archived',
|
||||
passwordHash: await bcrypt.hash('secret', 4),
|
||||
isActive: true,
|
||||
isArchived: true,
|
||||
roles: [],
|
||||
}),
|
||||
save: jest.fn(),
|
||||
};
|
||||
const service = new AuthService(
|
||||
userRepo as never,
|
||||
{ sign: jest.fn() } as never,
|
||||
{ getUserPermissions: jest.fn() } as never,
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.login({ username: 'archived', password: 'secret' }, '192.0.2.10'),
|
||||
).rejects.toThrow('账号已失效');
|
||||
expect(userRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,7 +38,9 @@ export class AuthService {
|
||||
this.recordFailedAttempt(attemptKey);
|
||||
throw new UnauthorizedException('用户名或密码错误');
|
||||
}
|
||||
if (!user.isActive) throw new UnauthorizedException('账号已被禁用,请联系管理员');
|
||||
if (!user.isActive || user.isArchived) {
|
||||
throw new UnauthorizedException('账号已失效,请联系管理员');
|
||||
}
|
||||
const valid = await bcrypt.compare(dto.password, user.passwordHash);
|
||||
if (!valid) {
|
||||
this.recordFailedAttempt(attemptKey);
|
||||
|
||||
@@ -33,6 +33,23 @@ describe('JwtStrategy', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('recognizes the canonical super_admin role code even when the display name changes', async () => {
|
||||
const userRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
isActive: true,
|
||||
isArchived: false,
|
||||
roles: [{ name: '系统管理员', code: 'super_admin', status: 1, permissions: [] }],
|
||||
}),
|
||||
};
|
||||
const strategy = new JwtStrategy(config as never, userRepo as never);
|
||||
|
||||
await expect(strategy.validate({ sub: 1 })).resolves.toEqual(
|
||||
expect.objectContaining({ isSuperAdmin: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ id: 7, isActive: false, isArchived: false, roles: [] }],
|
||||
[{ id: 7, isActive: true, isArchived: true, roles: [] }],
|
||||
|
||||
@@ -47,7 +47,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
for (const role of user.roles ?? []) {
|
||||
if (role.status !== 1) continue;
|
||||
roles.push(role.name);
|
||||
if (role.name === '超管' || role.name === 'super_admin') isSuperAdmin = true;
|
||||
if (role.name === '超管' || role.name === 'super_admin' || role.code === 'super_admin') {
|
||||
isSuperAdmin = true;
|
||||
}
|
||||
for (const permission of role.permissions ?? []) permissions.add(permission.code);
|
||||
}
|
||||
|
||||
|
||||
77
apps/server/src/bills/bills.boundaries.spec.ts
Normal file
77
apps/server/src/bills/bills.boundaries.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { BillsService } from './bills.service';
|
||||
import { Bill } from '../entities/bill.entity';
|
||||
|
||||
function queryBuilder() {
|
||||
return {
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
execute: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||
};
|
||||
}
|
||||
|
||||
function createService(bills: Partial<Bill>[] = []) {
|
||||
const billRepo = {
|
||||
find: jest.fn().mockResolvedValue(bills),
|
||||
findOne: jest.fn().mockResolvedValue(bills[0] ?? null),
|
||||
save: jest.fn(async (value) => value),
|
||||
createQueryBuilder: jest.fn(() => queryBuilder()),
|
||||
};
|
||||
const manager = {
|
||||
delete: jest.fn(),
|
||||
update: jest.fn(),
|
||||
};
|
||||
const dataSource = { transaction: jest.fn(async (callback) => callback(manager)) };
|
||||
const service = new BillsService(
|
||||
billRepo as any,
|
||||
{ delete: jest.fn() } as any,
|
||||
{} as any,
|
||||
{ update: jest.fn() } as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
dataSource as any,
|
||||
{} as any,
|
||||
);
|
||||
return { service, billRepo, dataSource, manager };
|
||||
}
|
||||
|
||||
describe('BillsService state and batch boundaries', () => {
|
||||
it('rejects an empty batch status update', async () => {
|
||||
const { service, billRepo } = createService();
|
||||
await expect(service.batchUpdateStatus([], 'paid')).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(billRepo.find).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a batch status update when some ids do not exist', async () => {
|
||||
const { service, billRepo } = createService([{ id: 1, paidAmount: 0, outstandingAmount: 10 }]);
|
||||
await expect(service.batchUpdateStatus([1, 2], 'unpaid')).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(billRepo.createQueryBuilder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects marking a partially paid bill unpaid', async () => {
|
||||
const { service, billRepo } = createService([{ id: 1, paidAmount: 10, outstandingAmount: 90, status: 'partially_paid' }]);
|
||||
await expect(service.updateStatus(1, { status: 'unpaid' })).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(billRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an empty batch delete', async () => {
|
||||
const { service, dataSource } = createService();
|
||||
await expect(service.batchRemove([])).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a batch delete when some ids do not exist', async () => {
|
||||
const { service, dataSource } = createService([{ id: 1, paidAmount: 0, status: 'unpaid' }]);
|
||||
await expect(service.batchRemove([1, 2])).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes a bill and its links in one transaction', async () => {
|
||||
const { service, dataSource, manager } = createService([{ id: 1, paidAmount: 0, status: 'unpaid' }]);
|
||||
await expect(service.remove(1)).resolves.toEqual({ message: '账单已删除' });
|
||||
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(manager.delete).toHaveBeenCalledTimes(2);
|
||||
expect(manager.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -544,3 +544,52 @@ describe('BillsService — generateBills', () => {
|
||||
expect(result.count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BillsService — allocation rounding boundary', () => {
|
||||
it('keeps allocated cents equal to the original expense total', async () => {
|
||||
const billRepo = mockRepo<Bill>();
|
||||
const itemRepo = mockRepo<BillItem>();
|
||||
const roomExpRepo = mockRepo<RoomExpense>();
|
||||
const personalExpRepo = mockRepo<PersonalExpense>();
|
||||
const occRepo = mockRepo<Occupancy>();
|
||||
const roomRepo = mockRepo<Room>();
|
||||
let nextBillId = 0;
|
||||
const dataSource = {
|
||||
query: jest.fn().mockResolvedValue([]),
|
||||
transaction: jest.fn(async (callback) => callback({
|
||||
create: (_entity: unknown, value: any) => value,
|
||||
save: jest.fn(async (value: any) => ({ id: value.id || ++nextBillId, ...value })),
|
||||
createQueryBuilder: jest.fn(() => ({
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
execute: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
const service = new BillsService(
|
||||
billRepo as any,
|
||||
itemRepo as any,
|
||||
roomExpRepo as any,
|
||||
personalExpRepo as any,
|
||||
occRepo as any,
|
||||
roomRepo as any,
|
||||
dataSource as any,
|
||||
{ debitBill: jest.fn(async (_manager, bill) => bill) } as any,
|
||||
);
|
||||
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder<RoomExpense>([
|
||||
{ id: 1, roomId: 1, expenseType: 'water', amount: 100, periodStart: '2026-06-01', periodEnd: '2026-06-30' } as RoomExpense,
|
||||
]));
|
||||
(occRepo.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder<Occupancy>([
|
||||
{ id: 1, roomId: 1, studentId: 1, stayType: 'short', billingStartDate: '2026-06-01', billingEndDate: '2026-06-01' } as Occupancy,
|
||||
{ id: 2, roomId: 1, studentId: 2, stayType: 'short', billingStartDate: '2026-06-01', billingEndDate: '2026-06-01' } as Occupancy,
|
||||
{ id: 3, roomId: 1, studentId: 3, stayType: 'short', billingStartDate: '2026-06-01', billingEndDate: '2026-06-01' } as Occupancy,
|
||||
]));
|
||||
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder<PersonalExpense>([]));
|
||||
|
||||
const result = await service.generateBills({ periodStart: '2026-06-01', periodEnd: '2026-06-30' } as any);
|
||||
|
||||
expect(result.bills.map((bill) => Number(bill.totalAmount))).toEqual([33.33, 33.33, 33.34]);
|
||||
expect(result.bills.reduce((sum, bill) => sum + Number(bill.totalAmount), 0)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,8 +32,11 @@ export class BillsService {
|
||||
const { periodStart, periodEnd } = dto.billingMonth
|
||||
? this.resolveBillingPeriod(dto.billingMonth)
|
||||
: { periodStart: dto.periodStart!, periodEnd: dto.periodEnd! };
|
||||
const pStart = new Date(periodStart);
|
||||
const pEnd = new Date(periodEnd);
|
||||
if (!this.isValidDate(periodStart) || !this.isValidDate(periodEnd) || periodEnd < periodStart) {
|
||||
throw new BadRequestException('账单周期无效,结束日期不能早于开始日期');
|
||||
}
|
||||
const pStart = new Date(`${periodStart}T00:00:00Z`);
|
||||
const pEnd = new Date(`${periodEnd}T00:00:00Z`);
|
||||
|
||||
const existingBills = await this.billRepo.find({ where: { periodStart, periodEnd } });
|
||||
if (existingBills.length > 0) {
|
||||
@@ -139,11 +142,16 @@ export class BillsService {
|
||||
|
||||
if (totalDays === 0) continue;
|
||||
|
||||
// 对每项费用进行分摊
|
||||
// 对每项费用进行分摊;最后一人承接舍入尾差,保证分摊合计与原费用一致。
|
||||
for (const expense of expenses) {
|
||||
for (const sd of studentDays) {
|
||||
if (sd.days === 0) continue;
|
||||
const amount = Number(((sd.days / totalDays) * Number(expense.amount)).toFixed(2));
|
||||
const eligibleDays = studentDays.filter((sd) => sd.days > 0);
|
||||
const expenseTotal = Number(Number(expense.amount).toFixed(2));
|
||||
let allocated = 0;
|
||||
for (const [index, sd] of eligibleDays.entries()) {
|
||||
const amount = index === eligibleDays.length - 1
|
||||
? Number((expenseTotal - allocated).toFixed(2))
|
||||
: Number(((sd.days / totalDays) * expenseTotal).toFixed(2));
|
||||
allocated = Number((allocated + amount).toFixed(2));
|
||||
if (!studentBillData.has(sd.studentId)) {
|
||||
studentBillData.set(sd.studentId, { shared: 0, items: [] });
|
||||
}
|
||||
@@ -189,16 +197,14 @@ export class BillsService {
|
||||
}
|
||||
|
||||
|
||||
// 合并所有涉及的学生
|
||||
// 合并所有涉及的学生,并在同一个事务中生成整批账单,避免中途失败留下半批数据。
|
||||
const allStudentIds = new Set([...studentBillData.keys(), ...personalMap.keys()]);
|
||||
// 生成账单
|
||||
const bills: Bill[] = [];
|
||||
for (const studentId of allStudentIds) {
|
||||
const shared = studentBillData.get(studentId)?.shared || 0;
|
||||
const personal = personalMap.get(studentId) || 0;
|
||||
const total = Number((shared + personal).toFixed(2));
|
||||
|
||||
const savedBill = await this.dataSource.transaction(async (manager) => {
|
||||
const bills = await this.dataSource.transaction(async (manager) => {
|
||||
const generated: Bill[] = [];
|
||||
for (const studentId of allStudentIds) {
|
||||
const shared = studentBillData.get(studentId)?.shared || 0;
|
||||
const personal = personalMap.get(studentId) || 0;
|
||||
const total = Number((shared + personal).toFixed(2));
|
||||
let bill = await manager.save(
|
||||
manager.create(Bill, {
|
||||
studentId,
|
||||
@@ -230,14 +236,20 @@ export class BillsService {
|
||||
.execute();
|
||||
}
|
||||
bill = await this.walletsService.debitBill(manager, bill);
|
||||
return bill;
|
||||
});
|
||||
bills.push(savedBill);
|
||||
}
|
||||
generated.push(bill);
|
||||
}
|
||||
return generated;
|
||||
});
|
||||
|
||||
return { message: `成功生成 ${bills.length} 条账单`, count: bills.length, bills, periodStart, periodEnd };
|
||||
}
|
||||
|
||||
private isValidDate(value: string) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false;
|
||||
const date = new Date(`${value}T00:00:00Z`);
|
||||
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
|
||||
}
|
||||
|
||||
private resolveBillingPeriod(billingMonth: string) {
|
||||
const matched = /^(\d{4})-(\d{2})$/.exec(billingMonth || '');
|
||||
if (!matched) throw new BadRequestException('账单月份格式错误,请使用 YYYY-MM');
|
||||
@@ -344,34 +356,36 @@ export class BillsService {
|
||||
async updateStatus(id: number, dto: UpdateBillStatusDto) {
|
||||
const bill = await this.billRepo.findOne({ where: { id } });
|
||||
if (!bill) throw new NotFoundException('账单不存在');
|
||||
if (dto.status === 'paid' && Number(bill.outstandingAmount) > 0) {
|
||||
throw new BadRequestException('存在未付金额,不能直接标记为已支付');
|
||||
}
|
||||
this.assertStatusMatchesAmounts(bill, dto.status);
|
||||
bill.status = dto.status;
|
||||
return this.billRepo.save(bill);
|
||||
}
|
||||
|
||||
async batchUpdateStatus(ids: number[], status: string) {
|
||||
const bills = await this.billRepo.find({ where: { id: In(ids) } });
|
||||
if (status === 'paid' && bills.some((bill) => Number(bill.outstandingAmount) > 0)) {
|
||||
throw new BadRequestException('选中账单存在未付金额,不能直接标记为已支付');
|
||||
}
|
||||
const uniqueIds = [...new Set(ids || [])];
|
||||
if (uniqueIds.length === 0) throw new BadRequestException('请选择要更新的账单');
|
||||
if (!['unpaid', 'partially_paid', 'paid'].includes(status)) throw new BadRequestException('账单状态无效');
|
||||
const bills = await this.billRepo.find({ where: { id: In(uniqueIds) } });
|
||||
if (bills.length !== uniqueIds.length) throw new NotFoundException('部分账单不存在');
|
||||
for (const bill of bills) this.assertStatusMatchesAmounts(bill, status);
|
||||
await this.billRepo
|
||||
.createQueryBuilder()
|
||||
.update()
|
||||
.set({ status })
|
||||
.where('id IN (:...ids)', { ids })
|
||||
.where('id IN (:...ids)', { ids: uniqueIds })
|
||||
.execute();
|
||||
return { message: `成功更新 ${ids.length} 条账单状态` };
|
||||
return { message: `成功更新 ${uniqueIds.length} 条账单状态` };
|
||||
}
|
||||
|
||||
async cancel(id: number, dto: CancelBillDto, recordedBy?: number) {
|
||||
const reason = dto.reason?.trim();
|
||||
if (!reason) throw new BadRequestException('取消原因不能为空');
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const bill = await manager.findOne(Bill, { where: { id } });
|
||||
if (!bill) throw new NotFoundException('账单不存在');
|
||||
if (bill.status === 'cancelled') throw new BadRequestException('账单已经取消');
|
||||
await manager.update(PersonalExpense, { billId: id }, { billId: null });
|
||||
return this.walletsService.refundBill(manager, bill, dto.reason, recordedBy);
|
||||
return this.walletsService.refundBill(manager, bill, reason, recordedBy);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -381,25 +395,38 @@ export class BillsService {
|
||||
if (Number(exists.paidAmount) > 0 || exists.status === 'cancelled') {
|
||||
throw new BadRequestException('已发生资金流水的账单不能删除,请使用取消账单');
|
||||
}
|
||||
await this.itemRepo.delete({ billId: id });
|
||||
await this.personalExpRepo.update({ billId: id }, { billId: null });
|
||||
await this.billRepo.delete(id);
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.delete(BillItem, { billId: id });
|
||||
await manager.update(PersonalExpense, { billId: id }, { billId: null });
|
||||
await manager.delete(Bill, id);
|
||||
});
|
||||
return { message: '账单已删除' };
|
||||
}
|
||||
|
||||
async batchRemove(ids: number[]) {
|
||||
const bills = await this.billRepo.find({ where: { id: In(ids) } });
|
||||
const uniqueIds = [...new Set(ids || [])];
|
||||
if (uniqueIds.length === 0) throw new BadRequestException('请选择要删除的账单');
|
||||
const bills = await this.billRepo.find({ where: { id: In(uniqueIds) } });
|
||||
if (bills.length !== uniqueIds.length) throw new NotFoundException('部分账单不存在');
|
||||
if (bills.some((bill) => Number(bill.paidAmount) > 0 || bill.status === 'cancelled')) {
|
||||
throw new BadRequestException('选中账单包含资金流水,不能批量删除');
|
||||
}
|
||||
await this.itemRepo.createQueryBuilder().delete().where('billId IN (:...ids)', { ids }).execute();
|
||||
await this.personalExpRepo
|
||||
.createQueryBuilder()
|
||||
.update()
|
||||
.set({ billId: null })
|
||||
.where('billId IN (:...ids)', { ids })
|
||||
.execute();
|
||||
await this.billRepo.createQueryBuilder().delete().where('id IN (:...ids)', { ids }).execute();
|
||||
return { message: `成功删除 ${ids.length} 条账单` };
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.delete(BillItem, { billId: In(uniqueIds) });
|
||||
await manager.update(PersonalExpense, { billId: In(uniqueIds) }, { billId: null });
|
||||
await manager.delete(Bill, uniqueIds);
|
||||
});
|
||||
return { message: `成功删除 ${uniqueIds.length} 条账单` };
|
||||
}
|
||||
|
||||
private assertStatusMatchesAmounts(bill: Bill, status: string) {
|
||||
const paid = Number(bill.paidAmount || 0);
|
||||
const outstanding = Number(bill.outstandingAmount || 0);
|
||||
const matches = status === 'paid'
|
||||
? outstanding <= 0
|
||||
: status === 'partially_paid'
|
||||
? paid > 0 && outstanding > 0
|
||||
: status === 'unpaid' && paid <= 0 && outstanding > 0;
|
||||
if (!matches) throw new BadRequestException('账单状态必须与实付及未付金额一致');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ArrayNotEmpty, IsArray, IsIn, IsInt, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
|
||||
import { ArrayNotEmpty, IsArray, IsIn, IsInt, IsNotEmpty, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
|
||||
|
||||
export class GenerateBillsDto {
|
||||
@IsString()
|
||||
@@ -21,6 +21,8 @@ export class UpdateBillStatusDto {
|
||||
|
||||
export class CancelBillDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Matches(/\S/)
|
||||
@MaxLength(300)
|
||||
reason: string;
|
||||
}
|
||||
|
||||
69
apps/server/src/classes/classes.boundaries.spec.ts
Normal file
69
apps/server/src/classes/classes.boundaries.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { ClassesService } from './classes.service';
|
||||
|
||||
function createService(classRepo: Record<string, jest.Mock>, classTeacherRepo = {}) {
|
||||
return new ClassesService(
|
||||
classRepo as never,
|
||||
{} as never,
|
||||
classTeacherRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ClassesService — archive and teacher boundaries', () => {
|
||||
it('rejects repeated archive and restore operations', async () => {
|
||||
await expect(
|
||||
createService({ findOne: jest.fn().mockResolvedValue({ isArchived: true }) }).archive(1),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(
|
||||
createService({ findOne: jest.fn().mockResolvedValue({ isArchived: false }) }).restore(1),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects assigning a teacher to a missing class', async () => {
|
||||
const classTeacherRepo = { findOne: jest.fn(), create: jest.fn(), save: jest.fn() };
|
||||
await expect(
|
||||
createService({ findOne: jest.fn().mockResolvedValue(null) }, classTeacherRepo).addTeacher(
|
||||
9,
|
||||
{
|
||||
userId: 2,
|
||||
roleType: 'head_teacher',
|
||||
},
|
||||
),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(classTeacherRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects duplicate teacher roles', async () => {
|
||||
const classTeacherRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({ id: 3 }),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
};
|
||||
await expect(
|
||||
createService(
|
||||
{ findOne: jest.fn().mockResolvedValue({ id: 1 }) },
|
||||
classTeacherRepo,
|
||||
).addTeacher(1, {
|
||||
userId: 2,
|
||||
roleType: 'head_teacher',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects removing a teacher or assignment that is not in the class', async () => {
|
||||
const classTeacherRepo = {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
const service = createService({}, classTeacherRepo);
|
||||
await expect(service.removeTeacher(1, 2)).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(service.removeTeacherAssignment(1, 3)).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(classTeacherRepo.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -41,7 +41,10 @@ describe('ClassesService — teacher data scope', () => {
|
||||
it('clears denormalized teacher ids when the last teacher for that role is removed', async () => {
|
||||
const classRepo = { update: jest.fn() };
|
||||
const classTeacherRepo = {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
find: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce([{ id: 1, classId: 8, userId: 21 }])
|
||||
.mockResolvedValueOnce([]),
|
||||
delete: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||
};
|
||||
const service = new ClassesService(
|
||||
|
||||
@@ -286,6 +286,7 @@ export class ClassesService {
|
||||
async archive(id: number) {
|
||||
const cls = await this.classRepo.findOne({ where: { id } });
|
||||
if (!cls) throw new NotFoundException('班级不存在');
|
||||
if (cls.isArchived) throw new BadRequestException('班级已归档');
|
||||
await this.classRepo.update(id, { isArchived: true });
|
||||
return { success: true };
|
||||
}
|
||||
@@ -294,6 +295,7 @@ export class ClassesService {
|
||||
async restore(id: number) {
|
||||
const cls = await this.classRepo.findOne({ where: { id } });
|
||||
if (!cls) throw new NotFoundException('班级不存在');
|
||||
if (!cls.isArchived) throw new BadRequestException('班级未归档');
|
||||
await this.classRepo.update(id, { isArchived: false });
|
||||
return { success: true };
|
||||
}
|
||||
@@ -392,6 +394,9 @@ export class ClassesService {
|
||||
}
|
||||
|
||||
async addTeacher(classId: number, dto: AddTeacherDto) {
|
||||
const cls = await this.classRepo.findOne({ where: { id: classId } });
|
||||
if (!cls) throw new NotFoundException('班级不存在');
|
||||
|
||||
const existing = await this.classTeacherRepo.findOne({
|
||||
where: { classId, userId: dto.userId, roleType: dto.roleType },
|
||||
});
|
||||
@@ -410,12 +415,18 @@ export class ClassesService {
|
||||
}
|
||||
|
||||
async removeTeacher(classId: number, userId: number) {
|
||||
const assignments = await this.classTeacherRepo.find({ where: { classId, userId } });
|
||||
if (assignments.length === 0) throw new NotFoundException('教师未分配到该班级');
|
||||
await this.classTeacherRepo.delete({ classId, userId });
|
||||
await this.syncClassTeacherIds(classId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async removeTeacherAssignment(classId: number, assignmentId: number) {
|
||||
const assignment = await this.classTeacherRepo.findOne({
|
||||
where: { id: assignmentId, classId },
|
||||
});
|
||||
if (!assignment) throw new NotFoundException('教师角色分配不存在');
|
||||
await this.classTeacherRepo.delete({ id: assignmentId, classId });
|
||||
await this.syncClassTeacherIds(classId);
|
||||
return { success: true };
|
||||
|
||||
@@ -1,43 +1,81 @@
|
||||
import { IsOptional, IsString, IsNotEmpty, IsInt, IsArray, IsDateString, IsEnum, ArrayNotEmpty, ValidateNested } from 'class-validator';
|
||||
import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsInt,
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
ArrayNotEmpty,
|
||||
ValidateNested,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { Type, Transform } from 'class-transformer';
|
||||
import { ClassType, ClassStatus, TeacherRoleType } from '../../entities';
|
||||
|
||||
export class ClassTeacherItemDto {
|
||||
@IsInt()
|
||||
userId: number;
|
||||
|
||||
@IsEnum(TeacherRoleType)
|
||||
roleType: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
subject?: string;
|
||||
}
|
||||
|
||||
export class CreateClassDto {
|
||||
@IsString() @IsNotEmpty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@IsString() @IsNotEmpty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
code: string;
|
||||
|
||||
|
||||
@IsEnum(ClassType) @IsString() @IsNotEmpty()
|
||||
@IsEnum(ClassType)
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
classType: string;
|
||||
|
||||
@IsOptional() @IsDateString()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
startDate?: string;
|
||||
|
||||
@IsOptional() @IsDateString()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
endDate?: string;
|
||||
|
||||
@IsEnum(ClassStatus) @IsOptional() @IsString()
|
||||
@IsEnum(ClassStatus)
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@IsOptional() @IsInt()
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
headTeacherId?: number;
|
||||
|
||||
@IsOptional() @IsInt()
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
lifeTeacherId?: number;
|
||||
|
||||
@IsOptional() @IsInt()
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
academicTeacherId?: number;
|
||||
|
||||
@IsOptional() @IsInt()
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
maxStudents?: number;
|
||||
|
||||
@IsOptional() @IsString()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@IsOptional() @IsArray()
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsInt({ each: true })
|
||||
studentIds?: number[];
|
||||
|
||||
@IsOptional()
|
||||
@@ -46,55 +84,73 @@ export class CreateClassDto {
|
||||
@Type(() => ImportUserItem)
|
||||
users?: ImportUserItem[];
|
||||
|
||||
@IsOptional() @IsArray()
|
||||
teachers?: Array<{ userId: number; roleType: string; subject?: string }>;
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ClassTeacherItemDto)
|
||||
teachers?: ClassTeacherItemDto[];
|
||||
}
|
||||
|
||||
export class UpdateClassDto {
|
||||
@IsOptional() @IsString()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional() @IsString()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
code?: string;
|
||||
|
||||
|
||||
@IsEnum(ClassType) @IsOptional() @IsString()
|
||||
@IsEnum(ClassType)
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
classType?: string;
|
||||
|
||||
@IsOptional() @IsDateString()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
startDate?: string;
|
||||
|
||||
@IsOptional() @IsDateString()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
endDate?: string;
|
||||
|
||||
@IsEnum(ClassStatus) @IsOptional() @IsString()
|
||||
@IsEnum(ClassStatus)
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@IsOptional() @IsInt()
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
headTeacherId?: number;
|
||||
|
||||
@IsOptional() @IsInt()
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
lifeTeacherId?: number;
|
||||
|
||||
@IsOptional() @IsInt()
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
academicTeacherId?: number;
|
||||
|
||||
@IsOptional() @IsInt()
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
maxStudents?: number;
|
||||
|
||||
@IsOptional() @IsString()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class QueryClassDto {
|
||||
|
||||
@IsOptional() @IsString()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@IsOptional() @IsString()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
classType?: string;
|
||||
|
||||
@IsOptional() @IsString()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
keyword?: string;
|
||||
|
||||
@IsOptional()
|
||||
@@ -108,7 +164,8 @@ export class QueryClassDto {
|
||||
}
|
||||
|
||||
export class AddStudentsDto {
|
||||
@IsArray() @IsInt({ each: true })
|
||||
@IsArray()
|
||||
@IsInt({ each: true })
|
||||
studentIds: number[];
|
||||
}
|
||||
|
||||
@@ -119,23 +176,28 @@ export class AddTeacherDto {
|
||||
@IsEnum(TeacherRoleType)
|
||||
roleType: string;
|
||||
|
||||
@IsOptional() @IsString()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
subject?: string;
|
||||
}
|
||||
|
||||
export class QueryClassScheduleDto {
|
||||
@IsOptional() @IsDateString()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
startDate?: string;
|
||||
|
||||
@IsOptional() @IsDateString()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
export class QueryClassAttendanceSummaryDto {
|
||||
@IsOptional() @IsDateString()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
startDate?: string;
|
||||
|
||||
@IsOptional() @IsDateString()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
endDate?: string;
|
||||
}
|
||||
export class BatchImportStudentsDto {
|
||||
@@ -147,12 +209,15 @@ export class BatchImportStudentsDto {
|
||||
}
|
||||
|
||||
export class ImportUserItem {
|
||||
@IsString() @IsNotEmpty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
dingUserId: string;
|
||||
|
||||
@IsString() @IsNotEmpty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@IsOptional() @IsString()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
mobile?: string;
|
||||
}
|
||||
|
||||
42
apps/server/src/classroom-rentals/dto/rental.dto.spec.ts
Normal file
42
apps/server/src/classroom-rentals/dto/rental.dto.spec.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { validate } from 'class-validator';
|
||||
import { CreateRentalDto } from './rental.dto';
|
||||
|
||||
const createRental = (overrides: Partial<CreateRentalDto> = {}) =>
|
||||
Object.assign(new CreateRentalDto(), {
|
||||
classroomId: 1,
|
||||
lesseeOrganizationId: 2,
|
||||
startDate: '2026-08-01',
|
||||
endDate: '2026-08-31',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('classroom rental DTO boundaries', () => {
|
||||
it.each(['2026-02-31', '2026-08-01T00:00:00Z', '2026-8-1'])(
|
||||
'rejects invalid or non-date-only value %s',
|
||||
async (startDate) => {
|
||||
const errors = await validate(createRental({ startDate }));
|
||||
expect(errors.some((error) => error.property === 'startDate')).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['dailyRate', 0],
|
||||
['totalAmount', -1],
|
||||
] as const)('rejects non-positive %s', async (field, value) => {
|
||||
const errors = await validate(createRental({ [field]: value }));
|
||||
expect(errors.some((error) => error.property === field)).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts positive amounts and a leap-day date', async () => {
|
||||
await expect(
|
||||
validate(
|
||||
createRental({
|
||||
startDate: '2028-02-29',
|
||||
endDate: '2028-02-29',
|
||||
dailyRate: 0.01,
|
||||
totalAmount: 0.01,
|
||||
}),
|
||||
),
|
||||
).resolves.toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsOptional, IsString, IsInt, IsNumber, IsDateString } from 'class-validator';
|
||||
import { IsOptional, IsString, IsInt, IsNumber, IsISO8601, Matches, Min } from 'class-validator';
|
||||
|
||||
export class CreateRentalDto {
|
||||
@IsInt()
|
||||
@@ -11,18 +11,22 @@ export class CreateRentalDto {
|
||||
@IsInt()
|
||||
lesseeOrganizationId: number;
|
||||
|
||||
@IsDateString()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@IsISO8601({ strict: true })
|
||||
startDate: string;
|
||||
|
||||
@IsDateString()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@IsISO8601({ strict: true })
|
||||
endDate: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
dailyRate?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
totalAmount?: number;
|
||||
|
||||
@IsOptional()
|
||||
@@ -44,19 +48,23 @@ export class UpdateRentalDto {
|
||||
lesseeOrganizationId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@IsISO8601({ strict: true })
|
||||
startDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@IsISO8601({ strict: true })
|
||||
endDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
dailyRate?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
totalAmount?: number;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
SubjectName,
|
||||
} from '../authorization';
|
||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
import { DashboardGanttQueryDto, DashboardPeriodQueryDto } from './dto/dashboard-query.dto';
|
||||
|
||||
interface RequestUser {
|
||||
id: number;
|
||||
@@ -43,28 +44,18 @@ export class DashboardController {
|
||||
}
|
||||
|
||||
@Get('gantt')
|
||||
getGanttData(
|
||||
@Query('periodStart') periodStart?: string,
|
||||
@Query('periodEnd') periodEnd?: string,
|
||||
@Query('building') building?: string,
|
||||
) {
|
||||
return this.service.getGanttData({ periodStart, periodEnd, building });
|
||||
getGanttData(@Query() query: DashboardGanttQueryDto) {
|
||||
return this.service.getGanttData(query);
|
||||
}
|
||||
|
||||
@Get('expense-stats')
|
||||
getExpenseStats(
|
||||
@Query('periodStart') periodStart?: string,
|
||||
@Query('periodEnd') periodEnd?: string,
|
||||
) {
|
||||
return this.service.getExpenseStats(periodStart, periodEnd);
|
||||
getExpenseStats(@Query() query: DashboardPeriodQueryDto) {
|
||||
return this.service.getExpenseStats(query.periodStart, query.periodEnd);
|
||||
}
|
||||
|
||||
@Get('room-ranking')
|
||||
getRoomExpenseRanking(
|
||||
@Query('periodStart') periodStart?: string,
|
||||
@Query('periodEnd') periodEnd?: string,
|
||||
) {
|
||||
return this.service.getRoomExpenseRanking(periodStart, periodEnd);
|
||||
getRoomExpenseRanking(@Query() query: DashboardPeriodQueryDto) {
|
||||
return this.service.getRoomExpenseRanking(query.periodStart, query.periodEnd);
|
||||
}
|
||||
|
||||
@Get('class-attendance-ranking')
|
||||
|
||||
@@ -42,3 +42,45 @@ describe('DashboardService — teacher class scope', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardService — boundary conditions', () => {
|
||||
it('uses a deny-all predicate instead of an empty SQL IN list', async () => {
|
||||
const qb = createQb();
|
||||
const attendanceRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };
|
||||
const service = new DashboardService(
|
||||
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
|
||||
{} as never, attendanceRepo as never, {} as never, {} as never, {} as never,
|
||||
{} as never, {} as never,
|
||||
);
|
||||
|
||||
await (service as unknown as {
|
||||
getAttendanceTrend: (today: string, classIds: number[]) => Promise<unknown>;
|
||||
}).getAttendanceTrend('2026-07-14', []);
|
||||
|
||||
expect(qb.andWhere).toHaveBeenCalledWith('1 = 0');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['getGanttData', [{ periodStart: '2026-08-01', periodEnd: '2026-07-31' }]],
|
||||
['getExpenseStats', ['2026-08-01', '2026-07-31']],
|
||||
['getRoomExpenseRanking', ['2026-08-01', '2026-07-31']],
|
||||
] as const)('rejects a reversed period in %s', async (method, args) => {
|
||||
const service = new DashboardService(
|
||||
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
|
||||
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
|
||||
{} as never,
|
||||
);
|
||||
await expect((service[method] as (...values: never[]) => Promise<unknown>)(...(args as never[])))
|
||||
.rejects.toThrow('结束日期不能早于开始日期');
|
||||
});
|
||||
|
||||
it('uses the China calendar date when the server timezone is behind China', () => {
|
||||
const service = new DashboardService(
|
||||
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
|
||||
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
|
||||
{} as never,
|
||||
);
|
||||
expect((service as unknown as { getChinaDate: (date: Date) => string })
|
||||
.getChinaDate(new Date('2026-07-13T16:30:00.000Z'))).toBe('2026-07-14');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, IsNull, Not, MoreThanOrEqual, In } from 'typeorm';
|
||||
import { Room } from '../entities/room.entity';
|
||||
@@ -40,8 +40,7 @@ export class DashboardService {
|
||||
}
|
||||
|
||||
async getStats(accessibleClassIds?: number[]) {
|
||||
const today = new Date();
|
||||
const todayStr = today.toISOString().slice(0, 10);
|
||||
const todayStr = this.getChinaDate(new Date());
|
||||
const currentMonth = todayStr.slice(0, 7); // YYYY-MM
|
||||
|
||||
const totalRooms = await this.roomRepo.count({ where: { status: Not('archived') } });
|
||||
@@ -180,6 +179,10 @@ export class DashboardService {
|
||||
accessibleClassIds?: number[],
|
||||
) {
|
||||
if (accessibleClassIds) {
|
||||
if (accessibleClassIds.length === 0) {
|
||||
qb.andWhere('1 = 0');
|
||||
return;
|
||||
}
|
||||
qb.andWhere(`${alias}.classId IN (:...accessibleClassIds)`, { accessibleClassIds });
|
||||
}
|
||||
}
|
||||
@@ -260,6 +263,7 @@ export class DashboardService {
|
||||
|
||||
// 甘特图数据:每个宿舍的入住时间线
|
||||
async getGanttData(query?: { periodStart?: string; periodEnd?: string; building?: string }) {
|
||||
this.assertPeriodRange(query?.periodStart, query?.periodEnd);
|
||||
const qb = this.occRepo
|
||||
.createQueryBuilder('o')
|
||||
.leftJoinAndSelect('o.student', 'student')
|
||||
@@ -302,6 +306,7 @@ export class DashboardService {
|
||||
}
|
||||
// 费用统计
|
||||
async getExpenseStats(periodStart?: string, periodEnd?: string) {
|
||||
this.assertPeriodRange(periodStart, periodEnd);
|
||||
const qb = this.expRepo
|
||||
.createQueryBuilder('e')
|
||||
.select('e.expenseType', 'type')
|
||||
@@ -314,6 +319,7 @@ export class DashboardService {
|
||||
|
||||
// 各宿舍费用排行
|
||||
async getRoomExpenseRanking(periodStart?: string, periodEnd?: string) {
|
||||
this.assertPeriodRange(periodStart, periodEnd);
|
||||
const qb = this.expRepo
|
||||
.createQueryBuilder('e')
|
||||
.leftJoin('e.room', 'room')
|
||||
@@ -368,7 +374,7 @@ export class DashboardService {
|
||||
where: { status: 'available' as const },
|
||||
order: { building: 'ASC', name: 'ASC' },
|
||||
});
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const today = this.getChinaDate(new Date());
|
||||
const schedQb = this.scheduleRepo
|
||||
.createQueryBuilder('s')
|
||||
.select('s.classroomId', 'classroomId')
|
||||
@@ -400,12 +406,31 @@ export class DashboardService {
|
||||
}));
|
||||
}
|
||||
|
||||
private assertPeriodRange(periodStart?: string, periodEnd?: string) {
|
||||
if (periodStart && periodEnd && periodStart > periodEnd) {
|
||||
throw new BadRequestException('结束日期不能早于开始日期');
|
||||
}
|
||||
}
|
||||
|
||||
private getChinaDate(date: Date): string {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).formatToParts(date);
|
||||
const values = Object.fromEntries(
|
||||
parts.filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]),
|
||||
);
|
||||
return `${values.year}-${values.month}-${values.day}`;
|
||||
}
|
||||
|
||||
async getClassroomUtilizationStats() {
|
||||
const totalClassrooms = await this.classroomRepo.count({
|
||||
where: { status: 'available' as const },
|
||||
});
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const today = this.getChinaDate(new Date());
|
||||
|
||||
// Count classrooms with active schedules today
|
||||
const schedQb = this.scheduleRepo
|
||||
|
||||
28
apps/server/src/dashboard/dto/dashboard-query.dto.spec.ts
Normal file
28
apps/server/src/dashboard/dto/dashboard-query.dto.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import 'reflect-metadata';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { DashboardGanttQueryDto, DashboardPeriodQueryDto } from './dashboard-query.dto';
|
||||
|
||||
describe('dashboard query boundaries', () => {
|
||||
it.each(['2026-02-31', '2026-07-13T00:00:00Z', '2026-7-13'])(
|
||||
'rejects invalid or non-date-only value %s',
|
||||
async (periodStart) => {
|
||||
const dto = plainToInstance(DashboardPeriodQueryDto, { periodStart });
|
||||
expect((await validate(dto)).some((error) => error.property === 'periodStart')).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it('accepts a valid date range and bounded building name', async () => {
|
||||
const dto = plainToInstance(DashboardGanttQueryDto, {
|
||||
periodStart: '2026-07-01',
|
||||
periodEnd: '2026-07-31',
|
||||
building: 'A座',
|
||||
});
|
||||
expect(await validate(dto)).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects an excessively long building name', async () => {
|
||||
const dto = plainToInstance(DashboardGanttQueryDto, { building: 'A'.repeat(51) });
|
||||
expect((await validate(dto)).some((error) => error.property === 'building')).toBe(true);
|
||||
});
|
||||
});
|
||||
20
apps/server/src/dashboard/dto/dashboard-query.dto.ts
Normal file
20
apps/server/src/dashboard/dto/dashboard-query.dto.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { IsISO8601, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
|
||||
|
||||
export class DashboardPeriodQueryDto {
|
||||
@IsOptional()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@IsISO8601({ strict: true })
|
||||
periodStart?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@IsISO8601({ strict: true })
|
||||
periodEnd?: string;
|
||||
}
|
||||
|
||||
export class DashboardGanttQueryDto extends DashboardPeriodQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
building?: string;
|
||||
}
|
||||
53
apps/server/src/deposits/deposits.boundaries.spec.ts
Normal file
53
apps/server/src/deposits/deposits.boundaries.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { DepositsService } from './deposits.service';
|
||||
import { Deposit } from '../entities/deposit.entity';
|
||||
|
||||
function serviceWith(deposit?: Partial<Deposit>) {
|
||||
const record = deposit ? ({ id: 1, studentId: 2, ...deposit } as Deposit) : null;
|
||||
const repo = {
|
||||
findOne: jest.fn(async (options: any) => options?.where?.studentId ? record : record),
|
||||
create: jest.fn((value) => value),
|
||||
save: jest.fn(async (value) => value),
|
||||
};
|
||||
const installmentRepo = {
|
||||
create: jest.fn((value) => value),
|
||||
save: jest.fn(async (value) => value),
|
||||
};
|
||||
const studentRepo = { findOne: jest.fn().mockResolvedValue({ id: 2 }) };
|
||||
return { service: new DepositsService(repo as any, installmentRepo as any, studentRepo as any), repo, installmentRepo };
|
||||
}
|
||||
|
||||
describe('DepositsService boundaries', () => {
|
||||
it('rejects an installment amount that rounds to zero', async () => {
|
||||
const { service, installmentRepo } = serviceWith({ amount: 500, status: 'paid' });
|
||||
|
||||
await expect(service.addInstallment(1, 0.004, '2026-08-01')).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(installmentRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a repeated full refund', async () => {
|
||||
const { service, repo } = serviceWith({ amount: 0, status: 'refunded' });
|
||||
|
||||
await expect(service.refund(1, { refundDate: '2026-07-14' })).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(repo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rounds cumulative collections and clears stale refund audit fields', async () => {
|
||||
const { service } = serviceWith({
|
||||
amount: 10.01,
|
||||
status: 'refunded',
|
||||
refundDate: '2026-07-01',
|
||||
refundAmount: 5,
|
||||
refundedBy: 9,
|
||||
refundedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await service.create({ studentId: 2, amount: 0.02, paidDate: '2026-07-14' }, 7);
|
||||
|
||||
expect(result).toMatchObject({ amount: 10.03, status: 'paid', recordedBy: 7 });
|
||||
expect(result.refundDate).toBeNull();
|
||||
expect(result.refundAmount).toBeNull();
|
||||
expect(result.refundedBy).toBeNull();
|
||||
expect(result.refundedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,8 @@ import { DepositInstallment } from '../entities/deposit-installment.entity';
|
||||
|
||||
import { CreateDepositDto, RefundDepositDto } from './dto/deposit.dto';
|
||||
|
||||
const money = (value: number | string | null | undefined) => Number(Number(value || 0).toFixed(2));
|
||||
|
||||
@Injectable()
|
||||
export class DepositsService {
|
||||
|
||||
@@ -46,14 +48,22 @@ export class DepositsService {
|
||||
async create(dto: CreateDepositDto, userId?: number) {
|
||||
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
|
||||
if (!student) throw new NotFoundException('学生不存在');
|
||||
if (Number(dto.amount) <= 0) throw new BadRequestException('收取金额必须大于0');
|
||||
const amount = money(dto.amount);
|
||||
if (!Number.isFinite(dto.amount) || Math.abs(dto.amount * 100 - Math.round(dto.amount * 100)) > 1e-8) {
|
||||
throw new BadRequestException('收取金额最多保留两位小数');
|
||||
}
|
||||
if (amount <= 0) throw new BadRequestException('收取金额必须大于0');
|
||||
|
||||
const existing = await this.repo.findOne({ where: { studentId: dto.studentId } });
|
||||
if (existing) {
|
||||
existing.amount = Number((Number(existing.amount || 0) + Number(dto.amount)).toFixed(2));
|
||||
existing.amount = money(Number(existing.amount || 0) + amount);
|
||||
existing.paidDate = dto.paidDate;
|
||||
existing.status = 'paid';
|
||||
existing.recordedBy = userId ?? null;
|
||||
existing.refundDate = null as unknown as string;
|
||||
existing.refundAmount = null as unknown as number;
|
||||
existing.refundedBy = null;
|
||||
existing.refundedAt = null;
|
||||
if (dto.notes) existing.notes = dto.notes;
|
||||
return this.repo.save(existing);
|
||||
}
|
||||
@@ -61,7 +71,7 @@ export class DepositsService {
|
||||
return this.repo.save(
|
||||
this.repo.create({
|
||||
studentId: dto.studentId,
|
||||
amount: dto.amount,
|
||||
amount,
|
||||
paidDate: dto.paidDate,
|
||||
notes: dto.notes,
|
||||
status: 'paid',
|
||||
@@ -71,12 +81,17 @@ export class DepositsService {
|
||||
}
|
||||
|
||||
async addInstallment(depositId: number, amount: number, dueDate: string) {
|
||||
const normalizedAmount = money(amount);
|
||||
if (!Number.isFinite(amount) || Math.abs(amount * 100 - Math.round(amount * 100)) > 1e-8) {
|
||||
throw new BadRequestException('分期金额最多保留两位小数');
|
||||
}
|
||||
if (normalizedAmount <= 0) throw new BadRequestException('分期金额必须大于0');
|
||||
const deposit = await this.repo.findOne({ where: { id: depositId } });
|
||||
if (!deposit) throw new NotFoundException('押金记录不存在');
|
||||
|
||||
const installment = this.installmentRepo.create({
|
||||
depositId,
|
||||
amount,
|
||||
amount: normalizedAmount,
|
||||
dueDate,
|
||||
status: 'pending',
|
||||
});
|
||||
@@ -106,7 +121,7 @@ export class DepositsService {
|
||||
throw new BadRequestException('该学生当前没有可退押金');
|
||||
}
|
||||
|
||||
const refundAmount = Number(deposit.amount);
|
||||
const refundAmount = money(deposit.amount);
|
||||
|
||||
deposit.refundDate = dto.refundDate;
|
||||
deposit.refundAmount = refundAmount;
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { IsString, IsOptional, IsInt, IsBoolean, IsIn } from 'class-validator';
|
||||
import { IsString, IsOptional, IsInt, IsBoolean, IsIn, IsNotEmpty, Matches, MaxLength, Min } from 'class-validator';
|
||||
|
||||
export class CreateExpenseTypeDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(30)
|
||||
@Matches(/^[a-z][a-z0-9_]*$/)
|
||||
code: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(30)
|
||||
@Matches(/\S/)
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@@ -13,12 +19,16 @@ export class CreateExpenseTypeDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export class UpdateExpenseTypeDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(30)
|
||||
@Matches(/\S/)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@@ -27,6 +37,7 @@ export class UpdateExpenseTypeDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
68
apps/server/src/expense-types/expense-types.service.spec.ts
Normal file
68
apps/server/src/expense-types/expense-types.service.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import 'reflect-metadata';
|
||||
import { validate } from 'class-validator';
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { CreateExpenseTypeDto, UpdateExpenseTypeDto } from './dto/expense-type.dto';
|
||||
import { ExpenseTypesService } from './expense-types.service';
|
||||
|
||||
describe('ExpenseTypesService boundaries', () => {
|
||||
const createService = () => {
|
||||
const repo = {
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn((value) => value),
|
||||
save: jest.fn(async (value) => ({ id: 1, ...value })),
|
||||
remove: jest.fn(),
|
||||
};
|
||||
return { service: new ExpenseTypesService(repo as never), repo };
|
||||
};
|
||||
|
||||
it('normalizes code and name before duplicate detection and save', async () => {
|
||||
const { service, repo } = createService();
|
||||
repo.findOne.mockResolvedValue(null);
|
||||
await service.create({ code: ' water ', name: ' 水费 ' });
|
||||
expect(repo.findOne).toHaveBeenCalledWith({ where: { code: 'water' } });
|
||||
expect(repo.create).toHaveBeenCalledWith({ code: 'water', name: '水费' });
|
||||
});
|
||||
|
||||
it('rejects a normalized duplicate code', async () => {
|
||||
const { service, repo } = createService();
|
||||
repo.findOne.mockResolvedValue({ id: 1, code: 'water' });
|
||||
await expect(service.create({ code: ' water ', name: '水费' }))
|
||||
.rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('trims an updated name and preserves omitted fields', async () => {
|
||||
const { service, repo } = createService();
|
||||
repo.findOne.mockResolvedValue({ id: 1, code: 'water', name: '旧名称', enabled: true });
|
||||
await service.update(1, { name: ' 新名称 ' });
|
||||
expect(repo.save).toHaveBeenCalledWith(expect.objectContaining({
|
||||
code: 'water', name: '新名称', enabled: true,
|
||||
}));
|
||||
});
|
||||
|
||||
it('rejects removal of a missing type', async () => {
|
||||
const { service, repo } = createService();
|
||||
repo.findOne.mockResolvedValue(null);
|
||||
await expect(service.remove(999)).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(repo.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('expense type DTO boundaries', () => {
|
||||
it.each(['Water', '1water', 'water-fee', '', 'a'.repeat(31)])('rejects code %j', async (code) => {
|
||||
const dto = Object.assign(new CreateExpenseTypeDto(), { code, name: '水费' });
|
||||
expect((await validate(dto)).some((error) => error.property === 'code')).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['', ' '])('rejects blank name %j and negative sort order', async (name) => {
|
||||
const dto = Object.assign(new CreateExpenseTypeDto(), {
|
||||
code: 'water', name, sortOrder: -1,
|
||||
});
|
||||
const properties = (await validate(dto)).map((error) => error.property);
|
||||
expect(properties).toEqual(expect.arrayContaining(['name', 'sortOrder']));
|
||||
});
|
||||
|
||||
it('accepts zero sort order and boolean enabled update', async () => {
|
||||
const dto = Object.assign(new UpdateExpenseTypeDto(), { sortOrder: 0, enabled: false });
|
||||
expect(await validate(dto)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -52,14 +52,15 @@ export class ExpenseTypesService {
|
||||
}
|
||||
|
||||
async create(dto: CreateExpenseTypeDto): Promise<ExpenseType> {
|
||||
const exists = await this.repo.findOne({ where: { code: dto.code } });
|
||||
const normalized = { ...dto, code: dto.code.trim(), name: dto.name.trim() };
|
||||
const exists = await this.repo.findOne({ where: { code: normalized.code } });
|
||||
if (exists) throw new ConflictException('费用类型代码已存在');
|
||||
return this.repo.save(this.repo.create(dto));
|
||||
return this.repo.save(this.repo.create(normalized));
|
||||
}
|
||||
|
||||
async update(id: number, dto: UpdateExpenseTypeDto): Promise<ExpenseType> {
|
||||
const t = await this.findOne(id);
|
||||
Object.assign(t, dto);
|
||||
Object.assign(t, dto, dto.name === undefined ? {} : { name: dto.name.trim() });
|
||||
return this.repo.save(t);
|
||||
}
|
||||
|
||||
|
||||
31
apps/server/src/expenses/dto/expense.dto.spec.ts
Normal file
31
apps/server/src/expenses/dto/expense.dto.spec.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import 'reflect-metadata';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { BatchRoomExpenseDto } from './expense.dto';
|
||||
|
||||
describe('BatchRoomExpenseDto boundaries', () => {
|
||||
it.each([
|
||||
{ expenses: [] },
|
||||
{ expenses: [{ roomId: 1, expenseType: 'water', amount: 0 }] },
|
||||
{ expenses: [{ roomId: 1, expenseType: 'water', amount: -1 }] },
|
||||
{ expenses: [{ roomId: 1, expenseType: 'water', amount: 1.001 }] },
|
||||
{ periodStart: '2026-02-31' },
|
||||
])('rejects invalid batch payload %#', async (override) => {
|
||||
const dto = plainToInstance(BatchRoomExpenseDto, {
|
||||
periodStart: '2026-07-01',
|
||||
periodEnd: '2026-07-31',
|
||||
expenses: [{ roomId: 1, expenseType: 'water', amount: 10 }],
|
||||
...override,
|
||||
});
|
||||
await expect(validate(dto)).resolves.not.toHaveLength(0);
|
||||
});
|
||||
|
||||
it('accepts a valid batch payload', async () => {
|
||||
const dto = plainToInstance(BatchRoomExpenseDto, {
|
||||
periodStart: '2026-07-01',
|
||||
periodEnd: '2026-07-31',
|
||||
expenses: [{ roomId: 1, expenseType: 'water', amount: 10.25 }],
|
||||
});
|
||||
await expect(validate(dto)).resolves.toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsDateString, IsIn, IsInt, IsString, IsNumber, IsOptional, Matches, Min } from 'class-validator';
|
||||
import { ArrayNotEmpty, IsArray, IsDateString, IsIn, IsInt, IsISO8601, IsString, IsNumber, IsOptional, Matches, Min, ValidateNested } from 'class-validator';
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
@@ -13,9 +13,13 @@ export class CreateRoomExpenseDto {
|
||||
@Min(0.01)
|
||||
amount: number;
|
||||
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@IsISO8601({ strict: true })
|
||||
@IsDateString()
|
||||
periodStart: string;
|
||||
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@IsISO8601({ strict: true })
|
||||
@IsDateString()
|
||||
periodEnd: string;
|
||||
|
||||
@@ -39,6 +43,8 @@ export class CreatePersonalExpenseDto {
|
||||
@Min(0.01)
|
||||
amount: number;
|
||||
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@IsISO8601({ strict: true })
|
||||
@IsDateString()
|
||||
expenseDate: string;
|
||||
|
||||
@@ -74,14 +80,38 @@ export class QueryPersonalExpenseDto {
|
||||
studentId?: number;
|
||||
}
|
||||
|
||||
export class BatchRoomExpenseDto {
|
||||
export class BatchRoomExpenseItemDto {
|
||||
@IsInt()
|
||||
roomId: number;
|
||||
|
||||
@IsString()
|
||||
expenseType: string;
|
||||
|
||||
@IsNumber({ maxDecimalPlaces: 2 })
|
||||
@Min(0.01)
|
||||
amount: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export class BatchRoomExpenseDto {
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@IsISO8601({ strict: true })
|
||||
@IsDateString()
|
||||
periodStart: string;
|
||||
|
||||
@IsString()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@IsISO8601({ strict: true })
|
||||
@IsDateString()
|
||||
periodEnd: string;
|
||||
|
||||
expenses: { roomId: number; expenseType: string; amount: number; description?: string }[];
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => BatchRoomExpenseItemDto)
|
||||
expenses: BatchRoomExpenseItemDto[];
|
||||
}
|
||||
|
||||
|
||||
|
||||
107
apps/server/src/expenses/expenses.boundaries.spec.ts
Normal file
107
apps/server/src/expenses/expenses.boundaries.spec.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { ExpensesService } from './expenses.service';
|
||||
import { PersonalExpense } from '../entities/personal-expense.entity';
|
||||
|
||||
const qb = (affected = 1) => ({
|
||||
delete: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
execute: jest.fn().mockResolvedValue({ affected }),
|
||||
});
|
||||
|
||||
function createService(options?: {
|
||||
roomFind?: any[];
|
||||
personalFind?: any[];
|
||||
roomExpense?: any;
|
||||
personalExpense?: any;
|
||||
}) {
|
||||
const roomExpRepo = {
|
||||
create: jest.fn((value) => value),
|
||||
save: jest.fn(async (value) => value),
|
||||
find: jest.fn().mockResolvedValue(options?.roomFind ?? []),
|
||||
findOne: jest.fn().mockResolvedValue(options?.roomExpense ?? null),
|
||||
createQueryBuilder: jest.fn(() => qb()),
|
||||
};
|
||||
const personalExpRepo = {
|
||||
create: jest.fn((value) => value),
|
||||
save: jest.fn(async (value) => value),
|
||||
find: jest.fn().mockResolvedValue(options?.personalFind ?? []),
|
||||
findOne: jest.fn().mockResolvedValue(options?.personalExpense ?? null),
|
||||
createQueryBuilder: jest.fn(() => qb()),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
const roomRepo = {
|
||||
find: jest.fn().mockImplementation(async () => options?.roomFind ?? []),
|
||||
findOne: jest.fn().mockResolvedValue({ id: 1 }),
|
||||
};
|
||||
const studentRepo = { findOne: jest.fn().mockResolvedValue({ id: 1 }) };
|
||||
return {
|
||||
service: new ExpensesService(roomExpRepo as any, personalExpRepo as any, roomRepo as any, studentRepo as any, {} as any),
|
||||
roomExpRepo,
|
||||
personalExpRepo,
|
||||
roomRepo,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ExpensesService boundaries', () => {
|
||||
it('rejects an empty room-expense batch', async () => {
|
||||
const { service, roomExpRepo } = createService();
|
||||
await expect(service.batchCreateRoomExpenses({
|
||||
periodStart: '2026-07-01',
|
||||
periodEnd: '2026-07-31',
|
||||
expenses: [],
|
||||
})).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(roomExpRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a batch when any room does not exist', async () => {
|
||||
const { service, roomExpRepo } = createService({ roomFind: [{ id: 1 }] });
|
||||
await expect(service.batchCreateRoomExpenses({
|
||||
periodStart: '2026-07-01',
|
||||
periodEnd: '2026-07-31',
|
||||
expenses: [
|
||||
{ roomId: 1, expenseType: 'water', amount: 10 },
|
||||
{ roomId: 2, expenseType: 'water', amount: 20 },
|
||||
],
|
||||
})).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(roomExpRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a reversed room-expense period', async () => {
|
||||
const { service } = createService();
|
||||
await expect(service.createRoomExpense({
|
||||
roomId: 1,
|
||||
expenseType: 'water',
|
||||
amount: 10,
|
||||
periodStart: '2026-08-01',
|
||||
periodEnd: '2026-07-31',
|
||||
})).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects a batch delete when only part of the ids exist', async () => {
|
||||
const { service, roomExpRepo } = createService({ roomFind: [{ id: 1 }] });
|
||||
await expect(service.batchDeleteRoomExpenses([1, 2])).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(roomExpRepo.createQueryBuilder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not edit or delete a personal expense already linked to a bill', async () => {
|
||||
const linked = { id: 1, studentId: 1, amount: 20, billId: 9 } as PersonalExpense;
|
||||
const { service, personalExpRepo } = createService({ personalExpense: linked });
|
||||
|
||||
await expect(service.updatePersonalExpense(1, { amount: 30 })).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(service.deletePersonalExpense(1)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(personalExpRepo.save).not.toHaveBeenCalled();
|
||||
expect(personalExpRepo.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a personal-expense batch delete containing billed records', async () => {
|
||||
const { service, personalExpRepo } = createService({
|
||||
personalFind: [
|
||||
{ id: 1, billId: null },
|
||||
{ id: 2, billId: 9 },
|
||||
],
|
||||
});
|
||||
|
||||
await expect(service.batchDeletePersonalExpenses([1, 2])).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(personalExpRepo.createQueryBuilder).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,8 @@ export class ExpensesService {
|
||||
|
||||
// 宿舍费用
|
||||
async createRoomExpense(dto: CreateRoomExpenseDto, userId?: number) {
|
||||
this.assertValidPeriod(dto.periodStart, dto.periodEnd);
|
||||
this.assertPositiveAmount(dto.amount);
|
||||
const room = await this.roomRepo.findOne({ where: { id: dto.roomId } });
|
||||
if (!room) throw new NotFoundException('宿舍不存在');
|
||||
const entity = this.roomExpRepo.create({ ...dto, recordedBy: userId });
|
||||
@@ -49,6 +51,12 @@ export class ExpensesService {
|
||||
}
|
||||
|
||||
async batchCreateRoomExpenses(dto: BatchRoomExpenseDto, userId?: number) {
|
||||
this.assertValidPeriod(dto.periodStart, dto.periodEnd);
|
||||
if (!dto.expenses?.length) throw new BadRequestException('请至少填写一条费用');
|
||||
dto.expenses.forEach((expense) => this.assertPositiveAmount(expense.amount));
|
||||
const roomIds = [...new Set(dto.expenses.map((expense) => expense.roomId))];
|
||||
const existingRooms = await this.roomRepo.find({ where: { id: In(roomIds) }, select: ['id'] });
|
||||
if (existingRooms.length !== roomIds.length) throw new NotFoundException('部分宿舍不存在');
|
||||
const entities = dto.expenses.map((e) => {
|
||||
const entity = this.roomExpRepo.create({
|
||||
roomId: e.roomId,
|
||||
@@ -83,11 +91,14 @@ export class ExpensesService {
|
||||
}
|
||||
|
||||
async batchDeleteRoomExpenses(ids: number[]) {
|
||||
if (!ids || ids.length === 0) throw new BadRequestException('请选择要删除的记录');
|
||||
const uniqueIds = [...new Set(ids || [])];
|
||||
if (uniqueIds.length === 0) throw new BadRequestException('请选择要删除的记录');
|
||||
const existing = await this.roomExpRepo.find({ where: { id: In(uniqueIds) }, select: ['id'] });
|
||||
if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在');
|
||||
const result = await this.roomExpRepo
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.where('id IN (:...ids)', { ids })
|
||||
.where('id IN (:...ids)', { ids: uniqueIds })
|
||||
.execute();
|
||||
return { message: '批量删除成功', deleted: result.affected || 0 };
|
||||
}
|
||||
@@ -95,12 +106,40 @@ export class ExpensesService {
|
||||
async updateRoomExpense(id: number, dto: Partial<CreateRoomExpenseDto>) {
|
||||
const e = await this.roomExpRepo.findOne({ where: { id } });
|
||||
if (!e) throw new NotFoundException('费用记录不存在');
|
||||
const periodStart = dto.periodStart ?? e.periodStart;
|
||||
const periodEnd = dto.periodEnd ?? e.periodEnd;
|
||||
this.assertValidPeriod(periodStart, periodEnd);
|
||||
if (dto.amount !== undefined) this.assertPositiveAmount(dto.amount);
|
||||
if (dto.roomId !== undefined && dto.roomId !== e.roomId) {
|
||||
const room = await this.roomRepo.findOne({ where: { id: dto.roomId } });
|
||||
if (!room) throw new NotFoundException('宿舍不存在');
|
||||
}
|
||||
Object.assign(e, dto);
|
||||
return this.roomExpRepo.save(e);
|
||||
}
|
||||
|
||||
private assertPositiveAmount(amount: number) {
|
||||
if (!Number.isFinite(amount) || Math.abs(amount * 100 - Math.round(amount * 100)) > 1e-8) {
|
||||
throw new BadRequestException('费用金额最多保留两位小数');
|
||||
}
|
||||
if (amount <= 0) throw new BadRequestException('费用金额必须大于0');
|
||||
}
|
||||
|
||||
private assertValidPeriod(periodStart: string, periodEnd: string) {
|
||||
if (!this.isValidDate(periodStart) || !this.isValidDate(periodEnd) || periodEnd < periodStart) {
|
||||
throw new BadRequestException('账期无效,结束日期不能早于开始日期');
|
||||
}
|
||||
}
|
||||
|
||||
private isValidDate(value: string) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false;
|
||||
const date = new Date(`${value}T00:00:00Z`);
|
||||
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
|
||||
}
|
||||
|
||||
async createStudentUtilityBill(dto: CreateStudentUtilityBillDto, userId?: number) {
|
||||
if (dto.periodEnd < dto.periodStart) throw new BadRequestException('账期结束日期不能早于开始日期');
|
||||
this.assertValidPeriod(dto.periodStart, dto.periodEnd);
|
||||
this.assertPositiveAmount(dto.amount);
|
||||
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
|
||||
if (!student) throw new NotFoundException('学生不存在');
|
||||
const expense = await this.personalExpRepo.save(
|
||||
@@ -125,6 +164,7 @@ export class ExpensesService {
|
||||
|
||||
// 个人附加费
|
||||
async createPersonalExpense(dto: CreatePersonalExpenseDto, userId?: number) {
|
||||
this.assertPositiveAmount(dto.amount);
|
||||
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
|
||||
if (!student) throw new NotFoundException('学生不存在');
|
||||
const entity = this.personalExpRepo.create({ ...dto, recordedBy: userId });
|
||||
@@ -144,16 +184,23 @@ export class ExpensesService {
|
||||
async deletePersonalExpense(id: number) {
|
||||
const e = await this.personalExpRepo.findOne({ where: { id } });
|
||||
if (!e) throw new NotFoundException('费用记录不存在');
|
||||
if (e.billId) throw new BadRequestException('已计入账单的个人费用不能删除,请先取消账单');
|
||||
await this.personalExpRepo.delete(id);
|
||||
return { message: '删除成功' };
|
||||
}
|
||||
|
||||
async batchDeletePersonalExpenses(ids: number[]) {
|
||||
if (!ids || ids.length === 0) throw new BadRequestException('请选择要删除的记录');
|
||||
const uniqueIds = [...new Set(ids || [])];
|
||||
if (uniqueIds.length === 0) throw new BadRequestException('请选择要删除的记录');
|
||||
const existing = await this.personalExpRepo.find({ where: { id: In(uniqueIds) } });
|
||||
if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在');
|
||||
if (existing.some((expense) => expense.billId)) {
|
||||
throw new BadRequestException('选中记录包含已计入账单的个人费用');
|
||||
}
|
||||
const result = await this.personalExpRepo
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.where('id IN (:...ids)', { ids })
|
||||
.where('id IN (:...ids)', { ids: uniqueIds })
|
||||
.execute();
|
||||
return { message: '批量删除成功', deleted: result.affected || 0 };
|
||||
}
|
||||
@@ -161,6 +208,12 @@ export class ExpensesService {
|
||||
async updatePersonalExpense(id: number, dto: Partial<CreatePersonalExpenseDto>) {
|
||||
const e = await this.personalExpRepo.findOne({ where: { id } });
|
||||
if (!e) throw new NotFoundException('费用记录不存在');
|
||||
if (e.billId) throw new BadRequestException('已计入账单的个人费用不能修改,请先取消账单');
|
||||
if (dto.amount !== undefined) this.assertPositiveAmount(dto.amount);
|
||||
if (dto.studentId !== undefined && dto.studentId !== e.studentId) {
|
||||
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
|
||||
if (!student) throw new NotFoundException('学生不存在');
|
||||
}
|
||||
Object.assign(e, dto);
|
||||
return this.personalExpRepo.save(e);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ describe('IntegrationConfigService.testConnection', () => {
|
||||
}),
|
||||
};
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: jest.fn().mockResolvedValue({ accessToken: 'token' }),
|
||||
}) as never;
|
||||
|
||||
@@ -47,3 +48,52 @@ describe('IntegrationConfigService.testConnection', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('IntegrationConfigService security boundaries', () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('masks AppSecret without mutating the parsed source object', async () => {
|
||||
const content = JSON.stringify({
|
||||
config: { corpId: 'corp', agentId: 'agent', appSecret: 'top-secret' },
|
||||
});
|
||||
const configRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({ id: 1, type: 'THIRD' }),
|
||||
};
|
||||
const detailRepo = {
|
||||
find: jest.fn().mockResolvedValue([{ type: 'DINGTALK_SYNC', enable: true, content }]),
|
||||
};
|
||||
const service = new IntegrationConfigService(configRepo as never, detailRepo as never);
|
||||
|
||||
await expect(service.getThirdConfig()).resolves.toEqual([
|
||||
{
|
||||
type: 'DINGTALK',
|
||||
verify: true,
|
||||
config: { corpId: 'corp', agentId: 'agent' },
|
||||
},
|
||||
]);
|
||||
expect(JSON.parse(content).config.appSecret).toBe('top-secret');
|
||||
});
|
||||
|
||||
it('treats a non-2xx DingTalk token response as a failed connection even if it contains a token field', async () => {
|
||||
const configRepo = { findOne: jest.fn() };
|
||||
const detailRepo = { findOne: jest.fn() };
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: jest.fn().mockResolvedValue({ accessToken: 'must-not-be-used' }),
|
||||
}) as never;
|
||||
const service = new IntegrationConfigService(configRepo as never, detailRepo as never);
|
||||
|
||||
await expect(
|
||||
service.testConnection('DINGTALK' as never, {
|
||||
corpId: 'corp',
|
||||
agentId: 'agent',
|
||||
appSecret: 'secret',
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -205,13 +205,21 @@ export class IntegrationConfigService {
|
||||
|
||||
/** 调钉钉新版接口拿 access_token */
|
||||
private async fetchDingTalkToken(appKey: string, appSecret: string): Promise<string | null> {
|
||||
const res = await fetch('https://api.dingtalk.com/v1.0/oauth2/accessToken', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ appKey, appSecret }),
|
||||
});
|
||||
const body = (await res.json()) as { accessToken?: string; expireIn?: number };
|
||||
return body.accessToken || null;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10_000);
|
||||
try {
|
||||
const res = await fetch('https://api.dingtalk.com/v1.0/oauth2/accessToken', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ appKey, appSecret }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json()) as { accessToken?: string; expireIn?: number };
|
||||
return body.accessToken || null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析并脱敏:删掉 appSecret 后返回 config 对象 */
|
||||
@@ -219,9 +227,10 @@ export class IntegrationConfigService {
|
||||
if (!content) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
const cfg = parsed.config || parsed;
|
||||
if (cfg.appSecret) delete cfg.appSecret;
|
||||
return cfg;
|
||||
const source = parsed.config || parsed;
|
||||
if (!source || typeof source !== 'object' || Array.isArray(source)) return {};
|
||||
const { appSecret: _appSecret, ...masked } = source as Record<string, unknown>;
|
||||
return masked;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -174,3 +174,41 @@ describe('DingTalkService — attendance machine only group', () => {
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('DingTalkService — department user pagination boundaries', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
global.fetch = undefined as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
it('stops when DingTalk says there is another page but omits the next cursor', async () => {
|
||||
const service = new DingTalkService({} as never, {} as never);
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
json: jest.fn().mockResolvedValue({
|
||||
errcode: 0,
|
||||
errmsg: 'ok',
|
||||
result: {
|
||||
list: [{ userid: 'u1', name: 'Alice', mobile: '', dept_id_list: [1] }],
|
||||
has_more: true,
|
||||
},
|
||||
}),
|
||||
}) as jest.MockedFunction<typeof fetch>;
|
||||
|
||||
await expect((service as any).getDeptUsers('token', 1)).resolves.toHaveLength(1);
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops when the next cursor repeats the current cursor', async () => {
|
||||
const service = new DingTalkService({} as never, {} as never);
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
json: jest.fn().mockResolvedValue({
|
||||
errcode: 0,
|
||||
errmsg: 'ok',
|
||||
result: { list: [], has_more: true, next_cursor: 0 },
|
||||
}),
|
||||
}) as jest.MockedFunction<typeof fetch>;
|
||||
|
||||
await expect((service as any).getDeptUsers('token', 1)).resolves.toEqual([]);
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -279,8 +279,13 @@ export class DingTalkService {
|
||||
if (body.errcode === 0 && body.result) {
|
||||
all.push(...body.result.list);
|
||||
hasMore = body.result.has_more;
|
||||
if (hasMore && body.result.next_cursor !== undefined) {
|
||||
cursor = body.result.next_cursor;
|
||||
if (hasMore) {
|
||||
if (body.result.next_cursor === undefined || body.result.next_cursor === cursor) {
|
||||
this.logger.error(`获取部门 ${deptId} 用户失败: 分页游标未前进`);
|
||||
hasMore = false;
|
||||
} else {
|
||||
cursor = body.result.next_cursor;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
hasMore = false;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'reflect-metadata';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { NotificationQueryDto } from './notification.dto';
|
||||
import { CreateNotificationDto, NotificationQueryDto } from './notification.dto';
|
||||
|
||||
describe('NotificationQueryDto', () => {
|
||||
it('converts numeric query-string values before integer validation', async () => {
|
||||
@@ -15,3 +15,21 @@ describe('NotificationQueryDto', () => {
|
||||
expect(dto.limit).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('notification DTO boundaries', () => {
|
||||
it('rejects an empty recipient set', async () => {
|
||||
const dto = plainToInstance(CreateNotificationDto, {
|
||||
recipientIds: [],
|
||||
type: 'test',
|
||||
title: '标题',
|
||||
});
|
||||
await expect(validate(dto)).resolves.toEqual(expect.arrayContaining([expect.any(Object)]));
|
||||
});
|
||||
|
||||
it('rejects non-positive cursors and page sizes outside 1-100', async () => {
|
||||
for (const value of [{ after: '0' }, { limit: '0' }, { limit: '101' }]) {
|
||||
const dto = plainToInstance(NotificationQueryDto, value);
|
||||
expect(await validate(dto)).not.toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsArray, IsInt } from 'class-validator';
|
||||
import {
|
||||
ArrayNotEmpty,
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsArray,
|
||||
IsInt,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class CreateNotificationDto {
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsInt({ each: true })
|
||||
recipientIds: number[];
|
||||
|
||||
@@ -27,10 +37,13 @@ export class NotificationQueryDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
after?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
61
apps/server/src/notifications/notifications.service.spec.ts
Normal file
61
apps/server/src/notifications/notifications.service.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
|
||||
function createQueryBuilder() {
|
||||
return {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
}
|
||||
|
||||
describe('NotificationsService boundaries', () => {
|
||||
it('rejects empty recipients and de-duplicates repeated recipients', async () => {
|
||||
const repo = {
|
||||
save: jest.fn().mockImplementation(async (rows) => rows),
|
||||
};
|
||||
const service = new NotificationsService(repo as never, { emit: jest.fn() } as never);
|
||||
|
||||
await expect(
|
||||
service.create({ recipientIds: [], type: 'test', title: '标题' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
const saved = await service.create({ recipientIds: [1, 1, 2], type: 'test', title: '标题' });
|
||||
expect(saved).toHaveLength(2);
|
||||
expect(repo.save).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ recipientId: 1 }),
|
||||
expect.objectContaining({ recipientId: 2 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('clamps service-level page size to protect callers outside the controller', async () => {
|
||||
const qb = createQueryBuilder();
|
||||
const service = new NotificationsService(
|
||||
{ createQueryBuilder: jest.fn().mockReturnValue(qb) } as never,
|
||||
new EventEmitter2(),
|
||||
);
|
||||
|
||||
await service.findByUser(7, undefined, 1000);
|
||||
expect(qb.take).toHaveBeenCalledWith(100);
|
||||
});
|
||||
|
||||
it('does not allow marking another user notification as read', async () => {
|
||||
const repo = { findOne: jest.fn().mockResolvedValue(null), update: jest.fn() };
|
||||
const service = new NotificationsService(repo as never, new EventEmitter2());
|
||||
await expect(service.markRead(3, 7)).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(repo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats marking an already-read notification as idempotent', async () => {
|
||||
const repo = {
|
||||
findOne: jest.fn().mockResolvedValue({ id: 3, recipientId: 7, isRead: true }),
|
||||
update: jest.fn(),
|
||||
};
|
||||
const service = new NotificationsService(repo as never, new EventEmitter2());
|
||||
await expect(service.markRead(3, 7)).resolves.toBeUndefined();
|
||||
expect(repo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Subject, Observable } from 'rxjs';
|
||||
@@ -18,7 +18,10 @@ export class NotificationsService {
|
||||
) {}
|
||||
|
||||
async create(dto: CreateNotificationDto): Promise<Notification[]> {
|
||||
const notifications = dto.recipientIds.map((recipientId) => ({
|
||||
const recipientIds = [...new Set(dto.recipientIds)];
|
||||
if (recipientIds.length === 0) throw new BadRequestException('通知接收人不能为空');
|
||||
|
||||
const notifications = recipientIds.map((recipientId) => ({
|
||||
recipientId,
|
||||
type: dto.type,
|
||||
title: dto.title,
|
||||
@@ -36,16 +39,13 @@ export class NotificationsService {
|
||||
return saved;
|
||||
}
|
||||
|
||||
async findByUser(
|
||||
userId: number,
|
||||
after?: number,
|
||||
limit: number = 20,
|
||||
): Promise<Notification[]> {
|
||||
async findByUser(userId: number, after?: number, limit: number = 20): Promise<Notification[]> {
|
||||
const safeLimit = Math.min(Math.max(limit, 1), 100);
|
||||
const qb = this.repo
|
||||
.createQueryBuilder('n')
|
||||
.where('n.recipientId = :userId', { userId })
|
||||
.orderBy('n.createdAt', 'DESC')
|
||||
.take(limit);
|
||||
.take(safeLimit);
|
||||
|
||||
if (after !== undefined) {
|
||||
qb.andWhere('n.id < :after', { after });
|
||||
@@ -61,10 +61,10 @@ export class NotificationsService {
|
||||
}
|
||||
|
||||
async markRead(id: number, userId: number): Promise<void> {
|
||||
await this.repo.update(
|
||||
{ id, recipientId: userId },
|
||||
{ isRead: true, readAt: new Date() },
|
||||
);
|
||||
const notification = await this.repo.findOne({ where: { id, recipientId: userId } });
|
||||
if (!notification) throw new NotFoundException('通知不存在');
|
||||
if (notification.isRead) return;
|
||||
await this.repo.update({ id, recipientId: userId }, { isRead: true, readAt: new Date() });
|
||||
}
|
||||
|
||||
async markAllRead(userId: number): Promise<void> {
|
||||
|
||||
@@ -71,3 +71,30 @@ describe('manual occupancy DTO bed requirements', () => {
|
||||
expect(errors.some((error) => error.property === 'newLockerId')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('occupancy date boundaries', () => {
|
||||
it.each(['2026-02-31', '2026-07-13T00:00:00Z', '2026-7-13'])(
|
||||
'rejects invalid or non-date-only check-in date %s',
|
||||
async (checkInDate) => {
|
||||
const dto = Object.assign(new CheckInDto(), {
|
||||
studentId: 1,
|
||||
roomId: 2,
|
||||
checkInDate,
|
||||
bedId: 3,
|
||||
});
|
||||
|
||||
expect((await validate(dto)).some((error) => error.property === 'checkInDate')).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it('accepts a leap-day date', async () => {
|
||||
const dto = Object.assign(new CheckInDto(), {
|
||||
studentId: 1,
|
||||
roomId: 2,
|
||||
checkInDate: '2028-02-29',
|
||||
bedId: 3,
|
||||
});
|
||||
|
||||
await expect(validate(dto)).resolves.toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { IsArray, IsBoolean, IsInt, IsNumber, IsOptional, IsString, Min } from 'class-validator';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsISO8601,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CheckInDto {
|
||||
@IsInt()
|
||||
@@ -7,11 +17,13 @@ export class CheckInDto {
|
||||
@IsInt()
|
||||
roomId: number;
|
||||
|
||||
@IsString()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@IsISO8601({ strict: true })
|
||||
checkInDate: string; // YYYY-MM-DD
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@IsISO8601({ strict: true })
|
||||
billingStartDate?: string; // 默认=checkInDate,可调整
|
||||
|
||||
@IsOptional()
|
||||
@@ -40,11 +52,13 @@ export class CheckInDto {
|
||||
}
|
||||
|
||||
export class CheckOutDto {
|
||||
@IsString()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@IsISO8601({ strict: true })
|
||||
checkOutDate: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@IsISO8601({ strict: true })
|
||||
billingEndDate?: string; // 默认=checkOutDate
|
||||
|
||||
@IsOptional()
|
||||
@@ -56,11 +70,13 @@ export class TransferRoomDto {
|
||||
@IsInt()
|
||||
newRoomId: number;
|
||||
|
||||
@IsString()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@IsISO8601({ strict: true })
|
||||
transferDate: string; // YYYY-MM-DD
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@IsISO8601({ strict: true })
|
||||
oldBillingEndDate?: string; // 旧房计费截止日,默认=transferDate
|
||||
|
||||
@IsInt()
|
||||
@@ -70,7 +86,8 @@ export class TransferRoomDto {
|
||||
@IsInt()
|
||||
newLockerId?: number;
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@IsISO8601({ strict: true })
|
||||
newBillingStartDate?: string; // 新房计费起始日,默认=transferDate次日
|
||||
|
||||
@IsOptional()
|
||||
@@ -82,11 +99,13 @@ export class BatchCheckOutDto {
|
||||
@IsArray()
|
||||
ids: number[];
|
||||
|
||||
@IsString()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@IsISO8601({ strict: true })
|
||||
checkOutDate: string; // YYYY-MM-DD
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@IsISO8601({ strict: true })
|
||||
billingEndDate?: string; // 默认=checkOutDate
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -117,8 +117,9 @@ describe('OccupanciesService — manual check-in deposit', () => {
|
||||
expect(depositRepo.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not create another paid deposit when one already exists', async () => {
|
||||
const { service, depositRepo } = createService({ id: 99 } as Deposit);
|
||||
it('adds the collected amount to the existing student deposit', async () => {
|
||||
const existing = { id: 99, amount: 200, status: 'refunded' } as Deposit;
|
||||
const { service, depositRepo } = createService(existing);
|
||||
|
||||
await service.checkIn({
|
||||
studentId: 3,
|
||||
@@ -130,7 +131,15 @@ describe('OccupanciesService — manual check-in deposit', () => {
|
||||
});
|
||||
|
||||
expect(depositRepo.create).not.toHaveBeenCalled();
|
||||
expect(depositRepo.save).not.toHaveBeenCalled();
|
||||
expect(depositRepo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 99,
|
||||
amount: 1000,
|
||||
status: 'paid',
|
||||
paidDate: '2026-07-14',
|
||||
notes: '入住登记自动收取',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -274,3 +283,66 @@ describe('OccupanciesService — import student matching', () => {
|
||||
expect(result).toEqual(expect.objectContaining({ imported: 1, skipped: 0 }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('OccupanciesService — stay lifecycle boundaries', () => {
|
||||
it('rejects check-out before check-in without releasing resources', async () => {
|
||||
const occupancy = {
|
||||
id: 1,
|
||||
roomId: 2,
|
||||
bedId: 3,
|
||||
lockerId: 4,
|
||||
checkInDate: '2026-07-10',
|
||||
billingStartDate: '2026-07-10',
|
||||
checkOutDate: null,
|
||||
} as Occupancy;
|
||||
const occupancyRepo = {
|
||||
findOne: jest.fn().mockResolvedValue(occupancy),
|
||||
save: jest.fn(),
|
||||
} as any as Repository<Occupancy>;
|
||||
const roomRepo = { update: jest.fn() } as any as Repository<Room>;
|
||||
const bedRepo = { update: jest.fn() } as any as Repository<Bed>;
|
||||
const lockerRepo = { update: jest.fn() } as any as Repository<Locker>;
|
||||
const service = new OccupanciesService(
|
||||
occupancyRepo,
|
||||
roomRepo,
|
||||
{} as Repository<Student>,
|
||||
{} as Repository<Deposit>,
|
||||
bedRepo,
|
||||
lockerRepo,
|
||||
{} as Repository<any>,
|
||||
{} as DataSource,
|
||||
);
|
||||
|
||||
await expect(service.checkOut(1, { checkOutDate: '2026-07-09' })).rejects.toThrow(
|
||||
'退宿日期不能早于入住日期',
|
||||
);
|
||||
expect(occupancyRepo.save).not.toHaveBeenCalled();
|
||||
expect(bedRepo.update).not.toHaveBeenCalled();
|
||||
expect(lockerRepo.update).not.toHaveBeenCalled();
|
||||
expect(roomRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects check-in to a maintenance room', async () => {
|
||||
const occupancyRepo = {
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
count: jest.fn(),
|
||||
} as any as Repository<Occupancy>;
|
||||
const service = new OccupanciesService(
|
||||
occupancyRepo,
|
||||
{
|
||||
findOne: jest.fn().mockResolvedValue({ id: 2, capacity: 4, status: 'maintenance' }),
|
||||
} as any,
|
||||
{} as Repository<Student>,
|
||||
{} as Repository<Deposit>,
|
||||
{} as Repository<Bed>,
|
||||
{} as Repository<Locker>,
|
||||
{} as Repository<any>,
|
||||
{} as DataSource,
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.checkIn({ studentId: 1, roomId: 2, checkInDate: '2026-07-10', bedId: 3 }),
|
||||
).rejects.toThrow('该宿舍当前不可入住');
|
||||
expect(occupancyRepo.count).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,6 +47,8 @@ export class OccupanciesService {
|
||||
}
|
||||
|
||||
async checkIn(dto: CheckInDto, userId?: number) {
|
||||
this.assertDateOrder(dto.checkInDate, dto.billingStartDate, '计费起始日不能早于入住日期');
|
||||
|
||||
// 检查学生是否已有活跃入住
|
||||
const existing = await this.repo.findOne({
|
||||
where: { studentId: dto.studentId, checkOutDate: IsNull() },
|
||||
@@ -56,6 +58,9 @@ export class OccupanciesService {
|
||||
// 检查宿舍容量
|
||||
const room = await this.roomRepo.findOne({ where: { id: dto.roomId } });
|
||||
if (!room) throw new NotFoundException('宿舍不存在');
|
||||
if (room.status === 'archived' || room.status === 'maintenance') {
|
||||
throw new BadRequestException('该宿舍当前不可入住');
|
||||
}
|
||||
const count = await this.repo.count({ where: { roomId: dto.roomId, checkOutDate: IsNull() } });
|
||||
if (count >= room.capacity) throw new BadRequestException('宿舍已满');
|
||||
|
||||
@@ -138,6 +143,12 @@ export class OccupanciesService {
|
||||
const occ = await this.repo.findOne({ where: { id: occupancyId } });
|
||||
if (!occ) throw new NotFoundException('入住记录不存在');
|
||||
if (occ.checkOutDate) throw new BadRequestException('该记录已退宿');
|
||||
this.assertDateOrder(occ.checkInDate, dto.checkOutDate, '退宿日期不能早于入住日期');
|
||||
this.assertDateOrder(
|
||||
occ.billingStartDate || occ.checkInDate,
|
||||
dto.billingEndDate || dto.checkOutDate,
|
||||
'计费截止日不能早于计费起始日',
|
||||
);
|
||||
|
||||
occ.checkOutDate = dto.checkOutDate;
|
||||
occ.billingEndDate = dto.billingEndDate || dto.checkOutDate;
|
||||
@@ -166,6 +177,14 @@ export class OccupanciesService {
|
||||
const oldOcc = await runner.manager.findOne(Occupancy, { where: { id: occupancyId } });
|
||||
if (!oldOcc) throw new NotFoundException('入住记录不存在');
|
||||
if (oldOcc.checkOutDate) throw new BadRequestException('该记录已退宿');
|
||||
if (oldOcc.roomId === dto.newRoomId)
|
||||
throw new BadRequestException('目标宿舍不能与当前宿舍相同');
|
||||
this.assertDateOrder(oldOcc.checkInDate, dto.transferDate, '换房日期不能早于原入住日期');
|
||||
this.assertDateOrder(
|
||||
oldOcc.billingStartDate || oldOcc.checkInDate,
|
||||
dto.oldBillingEndDate || dto.transferDate,
|
||||
'原宿舍计费截止日不能早于计费起始日',
|
||||
);
|
||||
|
||||
// 退旧房
|
||||
oldOcc.checkOutDate = dto.transferDate;
|
||||
@@ -183,6 +202,9 @@ export class OccupanciesService {
|
||||
// 检查新房容量
|
||||
const newRoom = await runner.manager.findOne(Room, { where: { id: dto.newRoomId } });
|
||||
if (!newRoom) throw new NotFoundException('目标宿舍不存在');
|
||||
if (newRoom.status === 'archived' || newRoom.status === 'maintenance') {
|
||||
throw new BadRequestException('目标宿舍当前不可入住');
|
||||
}
|
||||
const count = await runner.manager.count(Occupancy, {
|
||||
where: { roomId: dto.newRoomId, checkOutDate: IsNull() },
|
||||
});
|
||||
@@ -209,6 +231,11 @@ export class OccupanciesService {
|
||||
const nextDay = new Date(transferDate);
|
||||
nextDay.setDate(nextDay.getDate() + 1);
|
||||
const defaultBillingStart = nextDay.toISOString().split('T')[0];
|
||||
this.assertDateOrder(
|
||||
dto.transferDate,
|
||||
dto.newBillingStartDate || defaultBillingStart,
|
||||
'新宿舍计费起始日不能早于换房日期',
|
||||
);
|
||||
|
||||
// 入住新房
|
||||
const newOcc = runner.manager.create(Occupancy, {
|
||||
@@ -321,6 +348,17 @@ export class OccupanciesService {
|
||||
errors.push(`${occ.student?.name || id}已退宿`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
this.assertDateOrder(occ.checkInDate, dto.checkOutDate, '退宿日期不能早于入住日期');
|
||||
this.assertDateOrder(
|
||||
occ.billingStartDate || occ.checkInDate,
|
||||
dto.billingEndDate || dto.checkOutDate,
|
||||
'计费截止日不能早于计费起始日',
|
||||
);
|
||||
} catch (error) {
|
||||
errors.push(`${occ.student?.name || id}: ${(error as BadRequestException).message}`);
|
||||
continue;
|
||||
}
|
||||
occ.checkOutDate = dto.checkOutDate;
|
||||
occ.billingEndDate = dto.billingEndDate || dto.checkOutDate;
|
||||
occ.checkOutReason = dto.checkOutReason || '';
|
||||
@@ -447,12 +485,25 @@ export class OccupanciesService {
|
||||
);
|
||||
}
|
||||
|
||||
// 3. 检查是否已有活跃入住
|
||||
const checkInDate = row.checkInDate?.trim() || new Date().toISOString().split('T')[0];
|
||||
const checkOutDate = row.checkOutDate?.trim();
|
||||
const billingStartDate = row.billingStartDate?.trim() || checkInDate;
|
||||
const isHistoricalRecord = Boolean(checkOutDate);
|
||||
this.assertDateOnly(checkInDate, '入住日期');
|
||||
this.assertDateOnly(billingStartDate, '计费起始日');
|
||||
this.assertDateOrder(checkInDate, billingStartDate, '计费起始日不能早于入住日期');
|
||||
if (checkOutDate) {
|
||||
this.assertDateOnly(checkOutDate, '退宿日期');
|
||||
this.assertDateOrder(checkInDate, checkOutDate, '退宿日期不能早于入住日期');
|
||||
this.assertDateOrder(billingStartDate, checkOutDate, '退宿日期不能早于计费起始日');
|
||||
}
|
||||
|
||||
// 3. 检查是否已有活跃入住(历史记录不影响当前入住)
|
||||
const existing = await this.repo.findOne({
|
||||
where: { studentId: student.id, checkOutDate: IsNull() },
|
||||
relations: ['room'],
|
||||
});
|
||||
if (existing) {
|
||||
if (existing && !isHistoricalRecord) {
|
||||
errors.push(
|
||||
`第${rowNum}行: ${row.name} 已在住(${existing.room?.roomNumber || '房间' + existing.roomId}),跳过`,
|
||||
);
|
||||
@@ -462,7 +513,7 @@ export class OccupanciesService {
|
||||
|
||||
// 4. 检查宿舍容量
|
||||
const count = await this.repo.count({ where: { roomId: room.id, checkOutDate: IsNull() } });
|
||||
if (count >= room.capacity) {
|
||||
if (!isHistoricalRecord && count >= room.capacity) {
|
||||
errors.push(
|
||||
`第${rowNum}行: 宿舍 ${row.roomNumber} 已满(${count}/${room.capacity}),跳过 ${row.name}`,
|
||||
);
|
||||
@@ -471,7 +522,6 @@ export class OccupanciesService {
|
||||
}
|
||||
|
||||
// 5. 匹配或创建床位、柜子,并校验是否可用
|
||||
const isHistoricalRecord = Boolean(row.checkOutDate?.trim());
|
||||
let bed: Bed | null = null;
|
||||
if (row.bedNumber?.trim()) {
|
||||
const bedNumber = row.bedNumber.trim();
|
||||
@@ -507,12 +557,11 @@ export class OccupanciesService {
|
||||
}
|
||||
|
||||
// 6. 创建入住记录
|
||||
const checkInDate = row.checkInDate?.trim() || new Date().toISOString().split('T')[0];
|
||||
const occData: any = {
|
||||
studentId: student.id,
|
||||
roomId: room.id,
|
||||
checkInDate,
|
||||
billingStartDate: row.billingStartDate?.trim() || checkInDate,
|
||||
billingStartDate,
|
||||
stayType: row.stayType || undefined,
|
||||
responsibleOrganizationId: student.organizationId,
|
||||
notes: row.notes || undefined,
|
||||
@@ -520,9 +569,9 @@ export class OccupanciesService {
|
||||
lockerId: locker?.id,
|
||||
};
|
||||
// 如果有退宿日期,直接记录
|
||||
if (row.checkOutDate?.trim()) {
|
||||
occData.checkOutDate = row.checkOutDate.trim();
|
||||
occData.billingEndDate = row.checkOutDate.trim();
|
||||
if (checkOutDate) {
|
||||
occData.checkOutDate = checkOutDate;
|
||||
occData.billingEndDate = checkOutDate;
|
||||
}
|
||||
await this.repo.save(this.repo.create(occData));
|
||||
|
||||
@@ -536,13 +585,15 @@ export class OccupanciesService {
|
||||
}
|
||||
|
||||
// 9. 自动收取押金(仅对新入住且非历史记录的学生)
|
||||
if (options?.autoDeposit && !row.checkOutDate?.trim()) {
|
||||
if (options?.autoDeposit && !isHistoricalRecord) {
|
||||
const existingDeposit = await this.depositRepo.findOne({
|
||||
where: { studentId: student.id },
|
||||
});
|
||||
if (existingDeposit) {
|
||||
existingDeposit.amount = Number(
|
||||
(Number(existingDeposit.amount || 0) + Number(options.depositAmount || 500)).toFixed(2),
|
||||
(Number(existingDeposit.amount || 0) + Number(options.depositAmount || 500)).toFixed(
|
||||
2,
|
||||
),
|
||||
);
|
||||
existingDeposit.status = 'paid';
|
||||
existingDeposit.paidDate = checkInDate;
|
||||
@@ -579,4 +630,26 @@ export class OccupanciesService {
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private assertDateOnly(value: string, label: string): void {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||
throw new BadRequestException(`${label}必须为有效的 YYYY-MM-DD 日期`);
|
||||
}
|
||||
const [year, month, day] = value.split('-').map(Number);
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
if (
|
||||
date.getUTCFullYear() !== year ||
|
||||
date.getUTCMonth() + 1 !== month ||
|
||||
date.getUTCDate() !== day
|
||||
) {
|
||||
throw new BadRequestException(`${label}必须为有效的 YYYY-MM-DD 日期`);
|
||||
}
|
||||
}
|
||||
|
||||
private assertDateOrder(start: string, end: string | undefined, message: string): void {
|
||||
this.assertDateOnly(start, '起始日期');
|
||||
if (!end) return;
|
||||
this.assertDateOnly(end, '结束日期');
|
||||
if (end < start) throw new BadRequestException(message);
|
||||
}
|
||||
}
|
||||
|
||||
74
apps/server/src/operation-logs/dto/operation-log.dto.ts
Normal file
74
apps/server/src/operation-logs/dto/operation-log.dto.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsISO8601,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class QueryOperationLogsDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
module?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
userId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@IsISO8601({ strict: true })
|
||||
startDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@IsISO8601({ strict: true })
|
||||
endDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page: number = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(200)
|
||||
pageSize: number = 50;
|
||||
}
|
||||
|
||||
export class CreateAuditLogDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(50)
|
||||
module: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
action: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
targetId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
targetType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
detail?: string;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { OperationLogsService } from './operation-logs.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
import { extractRequestInfo } from '../common/request-utils';
|
||||
import { CreateAuditLogDto, QueryOperationLogsDto } from './dto/operation-log.dto';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('operation-logs')
|
||||
@@ -11,29 +12,15 @@ export class OperationLogsController {
|
||||
|
||||
@Get()
|
||||
@RequirePermission('log:view')
|
||||
findAll(
|
||||
@Query('module') module?: string,
|
||||
@Query('userId') userId?: string,
|
||||
@Query('startDate') startDate?: string,
|
||||
@Query('endDate') endDate?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.service.findAll({
|
||||
module,
|
||||
userId: userId ? +userId : undefined,
|
||||
startDate,
|
||||
endDate,
|
||||
page: page ? +page : 1,
|
||||
pageSize: pageSize ? +pageSize : 50,
|
||||
});
|
||||
findAll(@Query() query: QueryOperationLogsDto) {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
|
||||
|
||||
@Post('audit')
|
||||
@RequirePermission('log:create')
|
||||
async createAuditLog(
|
||||
@Body() body: { module: string; action: string; targetId?: number; targetType?: string; detail?: string },
|
||||
@Body() body: CreateAuditLogDto,
|
||||
@Request() req: any,
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'reflect-metadata';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { QueryOperationLogsDto } from './dto/operation-log.dto';
|
||||
import { OperationLogsService } from './operation-logs.service';
|
||||
|
||||
const createQb = () => ({
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
|
||||
});
|
||||
|
||||
describe('operation log query boundaries', () => {
|
||||
it.each(['0', '-1', '1.5', 'abc'])('rejects invalid page %s', async (page) => {
|
||||
const dto = plainToInstance(QueryOperationLogsDto, { page });
|
||||
expect((await validate(dto)).some((error) => error.property === 'page')).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['0', '201', '1.5', 'abc'])('rejects invalid page size %s', async (pageSize) => {
|
||||
const dto = plainToInstance(QueryOperationLogsDto, { pageSize });
|
||||
expect((await validate(dto)).some((error) => error.property === 'pageSize')).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
it.each(['2026-02-31', '2026-07-13T00:00:00Z'])(
|
||||
'rejects invalid or non-date-only value %s',
|
||||
async (startDate) => {
|
||||
const dto = plainToInstance(QueryOperationLogsDto, { startDate });
|
||||
expect((await validate(dto)).some((error) => error.property === 'startDate')).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it('transforms valid pagination and applies its database window', async () => {
|
||||
const dto = plainToInstance(QueryOperationLogsDto, { page: '2', pageSize: '20' });
|
||||
expect(await validate(dto)).toEqual([]);
|
||||
const qb = createQb();
|
||||
const service = new OperationLogsService({ createQueryBuilder: jest.fn().mockReturnValue(qb) } as never);
|
||||
await service.findAll(dto);
|
||||
expect(qb.skip).toHaveBeenCalledWith(20);
|
||||
expect(qb.take).toHaveBeenCalledWith(20);
|
||||
});
|
||||
|
||||
it('rejects a reversed period before opening a query', async () => {
|
||||
const repo = { createQueryBuilder: jest.fn() };
|
||||
const service = new OperationLogsService(repo as never);
|
||||
await expect(service.findAll({ startDate: '2026-08-01', endDate: '2026-07-31' }))
|
||||
.rejects.toThrow('结束日期不能早于开始日期');
|
||||
expect(repo.createQueryBuilder).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { OperationLog } from '../entities/operation-log.entity';
|
||||
@@ -31,6 +31,9 @@ export class OperationLogsService {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) {
|
||||
if (query?.startDate && query?.endDate && query.startDate > query.endDate) {
|
||||
throw new BadRequestException('结束日期不能早于开始日期');
|
||||
}
|
||||
const qb = this.repo.createQueryBuilder('log').orderBy('log.createdAt', 'DESC');
|
||||
if (query?.module) qb.andWhere('log.module = :module', { module: query.module });
|
||||
if (query?.userId) qb.andWhere('log.userId = :userId', { userId: query.userId });
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { IsString, MinLength, IsOptional, IsArray, IsBoolean } from 'class-validator';
|
||||
import {
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateRoleDto {
|
||||
@IsString()
|
||||
@@ -10,6 +19,9 @@ export class CreateRoleDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@IsInt({ each: true })
|
||||
@Min(1, { each: true })
|
||||
permissionIds?: number[];
|
||||
}
|
||||
|
||||
@@ -24,6 +36,9 @@ export class UpdateRoleDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@IsInt({ each: true })
|
||||
@Min(1, { each: true })
|
||||
permissionIds?: number[];
|
||||
}
|
||||
|
||||
@@ -40,6 +55,9 @@ export class CreateUserDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@IsInt({ each: true })
|
||||
@Min(1, { each: true })
|
||||
roleIds?: number[];
|
||||
}
|
||||
|
||||
@@ -58,6 +76,9 @@ export class UpdateUserDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@IsInt({ each: true })
|
||||
@Min(1, { each: true })
|
||||
roleIds?: number[];
|
||||
}
|
||||
|
||||
@@ -79,4 +100,3 @@ export class UpdateProfileDto {
|
||||
@IsString()
|
||||
qualifications?: string;
|
||||
}
|
||||
|
||||
|
||||
95
apps/server/src/rbac/rbac.boundary.spec.ts
Normal file
95
apps/server/src/rbac/rbac.boundary.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { RbacService } from './rbac.service';
|
||||
import { CreateRoleDto, CreateUserDto, UpdateUserDto } from './dto/rbac.dto';
|
||||
|
||||
function makeService(overrides?: {
|
||||
permRepo?: Record<string, jest.Mock>;
|
||||
roleRepo?: Record<string, jest.Mock>;
|
||||
userRepo?: Record<string, jest.Mock>;
|
||||
}) {
|
||||
const permRepo = {
|
||||
findByIds: jest.fn().mockResolvedValue([]),
|
||||
...(overrides?.permRepo ?? {}),
|
||||
};
|
||||
const roleRepo = {
|
||||
create: jest.fn((value) => ({ ...value })),
|
||||
save: jest.fn(async (value) => value),
|
||||
findByIds: jest.fn().mockResolvedValue([]),
|
||||
findOneOrFail: jest.fn(),
|
||||
...(overrides?.roleRepo ?? {}),
|
||||
};
|
||||
const userRepo = {
|
||||
create: jest.fn((value) => ({ ...value })),
|
||||
save: jest.fn(async (value) => value),
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
...(overrides?.userRepo ?? {}),
|
||||
};
|
||||
return {
|
||||
service: new RbacService(
|
||||
permRepo as never,
|
||||
roleRepo as never,
|
||||
userRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
),
|
||||
permRepo,
|
||||
roleRepo,
|
||||
userRepo,
|
||||
};
|
||||
}
|
||||
|
||||
describe('RBAC mutation boundaries', () => {
|
||||
it('rejects a role when any requested permission id does not exist', async () => {
|
||||
const { service, roleRepo } = makeService({
|
||||
permRepo: { findByIds: jest.fn().mockResolvedValue([{ id: 1, code: 'student:view' }]) },
|
||||
});
|
||||
|
||||
await expect(service.createRole({ name: 'partial', permissionIds: [1, 999] })).rejects.toThrow(
|
||||
'权限不存在: 999',
|
||||
);
|
||||
expect(roleRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a user when any requested role id does not exist', async () => {
|
||||
const { service, userRepo } = makeService({
|
||||
roleRepo: { findByIds: jest.fn().mockResolvedValue([{ id: 2, name: '老师' }]) },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createUser({
|
||||
username: 'alice',
|
||||
password: 'secret',
|
||||
name: 'Alice',
|
||||
roleIds: [2, 404],
|
||||
}),
|
||||
).rejects.toThrow('角色不存在: 404');
|
||||
expect(userRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows explicitly clearing all roles from an existing user', async () => {
|
||||
const user = { id: 7, username: 'alice', name: 'Alice', roles: [{ id: 2 }] };
|
||||
const { service, userRepo } = makeService({
|
||||
userRepo: { findOne: jest.fn().mockResolvedValue(user) },
|
||||
});
|
||||
|
||||
await expect(service.updateUser(7, { roleIds: [] })).resolves.toEqual({ message: '更新成功' });
|
||||
expect(user.roles).toEqual([]);
|
||||
expect(userRepo.save).toHaveBeenCalledWith(user);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RBAC DTO id arrays', () => {
|
||||
const pipe = new ValidationPipe({ transform: true, whitelist: true });
|
||||
|
||||
it.each([
|
||||
[CreateRoleDto, { name: 'role', permissionIds: [1, '2'] }],
|
||||
[CreateRoleDto, { name: 'role', permissionIds: [1, 1] }],
|
||||
[CreateUserDto, { username: 'alice', password: 'secret', name: 'Alice', roleIds: [0] }],
|
||||
[UpdateUserDto, { roleIds: [1.5] }],
|
||||
])('rejects invalid, duplicate, or non-positive ids for %p', async (metatype, value) => {
|
||||
await expect(pipe.transform(value, { type: 'body', metatype })).rejects.toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -301,7 +301,11 @@ export class RbacService {
|
||||
}
|
||||
}
|
||||
|
||||
if (role.code !== preset.code || role.name !== preset.name || role.description !== preset.description) {
|
||||
if (
|
||||
role.code !== preset.code ||
|
||||
role.name !== preset.name ||
|
||||
role.description !== preset.description
|
||||
) {
|
||||
role.code = preset.code;
|
||||
role.name = preset.name;
|
||||
role.description = preset.description;
|
||||
@@ -368,6 +372,28 @@ export class RbacService {
|
||||
return this.roleRepo.findOneOrFail({ where: { id }, relations: ['permissions'] });
|
||||
}
|
||||
|
||||
private async resolvePermissions(permissionIds: number[]): Promise<Permission[]> {
|
||||
const uniqueIds = [...new Set(permissionIds)];
|
||||
const permissions = uniqueIds.length > 0 ? await this.permRepo.findByIds(uniqueIds) : [];
|
||||
if (permissions.length !== uniqueIds.length) {
|
||||
const foundIds = new Set(permissions.map((permission) => permission.id));
|
||||
const missingIds = uniqueIds.filter((id) => !foundIds.has(id));
|
||||
throw new Error(`权限不存在: ${missingIds.join(',')}`);
|
||||
}
|
||||
return permissions;
|
||||
}
|
||||
|
||||
private async resolveRoles(roleIds: number[]): Promise<Role[]> {
|
||||
const uniqueIds = [...new Set(roleIds)];
|
||||
const roles = uniqueIds.length > 0 ? await this.roleRepo.findByIds(uniqueIds) : [];
|
||||
if (roles.length !== uniqueIds.length) {
|
||||
const foundIds = new Set(roles.map((role) => role.id));
|
||||
const missingIds = uniqueIds.filter((id) => !foundIds.has(id));
|
||||
throw new Error(`角色不存在: ${missingIds.join(',')}`);
|
||||
}
|
||||
return roles;
|
||||
}
|
||||
|
||||
async createRole(dto: {
|
||||
name: string;
|
||||
description?: string;
|
||||
@@ -375,7 +401,7 @@ export class RbacService {
|
||||
}): Promise<Role> {
|
||||
const role = this.roleRepo.create({ name: dto.name, description: dto.description });
|
||||
if (dto.permissionIds && dto.permissionIds.length > 0) {
|
||||
role.permissions = await this.permRepo.findByIds(dto.permissionIds);
|
||||
role.permissions = await this.resolvePermissions(dto.permissionIds);
|
||||
}
|
||||
return this.roleRepo.save(role);
|
||||
}
|
||||
@@ -392,7 +418,7 @@ export class RbacService {
|
||||
if (dto.description !== undefined) role.description = dto.description;
|
||||
if (dto.permissionIds !== undefined) {
|
||||
role.permissions =
|
||||
dto.permissionIds.length > 0 ? await this.permRepo.findByIds(dto.permissionIds) : [];
|
||||
dto.permissionIds.length > 0 ? await this.resolvePermissions(dto.permissionIds) : [];
|
||||
}
|
||||
return this.roleRepo.save(role);
|
||||
}
|
||||
@@ -472,7 +498,7 @@ export class RbacService {
|
||||
name: dto.name,
|
||||
});
|
||||
if (dto.roleIds && dto.roleIds.length > 0) {
|
||||
user.roles = await this.roleRepo.findByIds(dto.roleIds);
|
||||
user.roles = await this.resolveRoles(dto.roleIds);
|
||||
}
|
||||
await this.userRepo.save(user);
|
||||
return { message: '用户创建成功' };
|
||||
@@ -492,7 +518,7 @@ export class RbacService {
|
||||
if (dto.name !== undefined) user.name = dto.name;
|
||||
if (dto.isActive !== undefined) user.isActive = dto.isActive;
|
||||
if (dto.roleIds !== undefined) {
|
||||
user.roles = dto.roleIds.length > 0 ? await this.roleRepo.findByIds(dto.roleIds) : [];
|
||||
user.roles = dto.roleIds.length > 0 ? await this.resolveRoles(dto.roleIds) : [];
|
||||
}
|
||||
await this.userRepo.save(user);
|
||||
return { message: '更新成功' };
|
||||
|
||||
@@ -60,3 +60,30 @@ it('removes the retired departmentId field from create requests', async () => {
|
||||
|
||||
expect(dto).not.toHaveProperty('departmentId');
|
||||
});
|
||||
|
||||
describe('schedule boundary validation', () => {
|
||||
it.each(['24:00', '09:60', '99:99', '9:00'])('rejects invalid time %s', async (time) => {
|
||||
const dto = createSchedule('');
|
||||
dto.startTime = time;
|
||||
expect((await validate(dto)).some((error) => error.property === 'startTime')).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['2026-02-31', '2026-07-13T00:00:00Z', '2026-7-13'])(
|
||||
'rejects invalid or non-date-only value %s',
|
||||
async (date) => {
|
||||
const dto = createSchedule('');
|
||||
dto.startDate = date;
|
||||
expect((await validate(dto)).some((error) => error.property === 'startDate')).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it('accepts inclusive attendance-window limits and a leap-day date', async () => {
|
||||
const zero = createSchedule('');
|
||||
zero.attendanceAdvanceMinutes = 0;
|
||||
zero.startDate = '2028-02-29';
|
||||
const fullDay = createSchedule('');
|
||||
fullDay.attendanceAdvanceMinutes = 1440;
|
||||
expect(await validate(zero)).toEqual([]);
|
||||
expect(await validate(fullDay)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,8 @@ import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsInt,
|
||||
IsDateString,
|
||||
IsISO8601,
|
||||
IsMilitaryTime,
|
||||
Matches,
|
||||
Min,
|
||||
Max,
|
||||
@@ -26,11 +27,11 @@ export class CreateScheduleDto {
|
||||
@IsNotEmpty()
|
||||
weekDay: number;
|
||||
|
||||
@Matches(/^\d{2}:\d{2}$/)
|
||||
@IsMilitaryTime()
|
||||
@IsNotEmpty()
|
||||
startTime: string;
|
||||
|
||||
@Matches(/^\d{2}:\d{2}$/)
|
||||
@IsMilitaryTime()
|
||||
@IsNotEmpty()
|
||||
endTime: string;
|
||||
|
||||
@@ -40,11 +41,13 @@ export class CreateScheduleDto {
|
||||
@Max(1440)
|
||||
attendanceAdvanceMinutes?: number;
|
||||
|
||||
@IsDateString()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@IsISO8601({ strict: true })
|
||||
@IsNotEmpty()
|
||||
startDate: string;
|
||||
|
||||
@IsDateString()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@IsISO8601({ strict: true })
|
||||
@IsNotEmpty()
|
||||
endDate: string;
|
||||
|
||||
@@ -87,11 +90,11 @@ export class UpdateScheduleDto {
|
||||
weekDay?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Matches(/^\d{2}:\d{2}$/)
|
||||
@IsMilitaryTime()
|
||||
startTime?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Matches(/^\d{2}:\d{2}$/)
|
||||
@IsMilitaryTime()
|
||||
endTime?: string;
|
||||
|
||||
@IsOptional()
|
||||
@@ -101,11 +104,13 @@ export class UpdateScheduleDto {
|
||||
attendanceAdvanceMinutes?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@IsISO8601({ strict: true })
|
||||
startDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@IsISO8601({ strict: true })
|
||||
endDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@@ -149,21 +154,25 @@ export class QueryScheduleDto {
|
||||
weekDay?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@IsISO8601({ strict: true })
|
||||
startDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@IsISO8601({ strict: true })
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
export class WeeklyViewQueryDto {
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@IsISO8601({ strict: true })
|
||||
startDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@IsISO8601({ strict: true })
|
||||
endDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -339,3 +339,41 @@ describe('SchedulesService — remove', () => {
|
||||
expect(scheduleRepo.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SchedulesService — range boundaries', () => {
|
||||
const makeService = () => {
|
||||
const scheduleRepo = { create: jest.fn() };
|
||||
return {
|
||||
service: new SchedulesService(
|
||||
scheduleRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
),
|
||||
scheduleRepo,
|
||||
};
|
||||
};
|
||||
const valid = {
|
||||
classId: 1, classroomId: 2, weekDay: 1,
|
||||
startTime: '09:00', endTime: '10:00',
|
||||
startDate: '2026-07-01', endDate: '2026-07-31', subject: '数学',
|
||||
};
|
||||
|
||||
it('rejects zero-duration schedules before repository access', async () => {
|
||||
const { service, scheduleRepo } = makeService();
|
||||
await expect(service.create({ ...valid, endTime: '09:00' })).rejects.toThrow(
|
||||
'上课时间和下课时间不能相同',
|
||||
);
|
||||
expect(scheduleRepo.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects reversed date ranges before repository access', async () => {
|
||||
const { service, scheduleRepo } = makeService();
|
||||
await expect(
|
||||
service.create({ ...valid, startDate: '2026-08-01', endDate: '2026-07-31' }),
|
||||
).rejects.toThrow('排课结束日期不能早于开始日期');
|
||||
expect(scheduleRepo.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -179,7 +179,22 @@ export class SchedulesService {
|
||||
}
|
||||
}
|
||||
|
||||
private assertValidScheduleRange(
|
||||
startTime: string,
|
||||
endTime: string,
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
) {
|
||||
if (startTime === endTime) {
|
||||
throw new BadRequestException('上课时间和下课时间不能相同');
|
||||
}
|
||||
if (startDate > endDate) {
|
||||
throw new BadRequestException('排课结束日期不能早于开始日期');
|
||||
}
|
||||
}
|
||||
|
||||
async create(dto: CreateScheduleDto) {
|
||||
this.assertValidScheduleRange(dto.startTime, dto.endTime, dto.startDate, dto.endDate);
|
||||
await this.assertClassroomAvailable(dto.classroomId);
|
||||
await this.normalizeTeacherForSchedule(dto);
|
||||
await this.assertTeacherAssignedToClass(dto.classId, dto.teacherId);
|
||||
@@ -211,6 +226,7 @@ export class SchedulesService {
|
||||
const endTime = dto.endTime ?? existing.endTime;
|
||||
const startDate = dto.startDate ?? existing.startDate;
|
||||
const endDate = dto.endDate ?? existing.endDate;
|
||||
this.assertValidScheduleRange(startTime, endTime, startDate, endDate);
|
||||
|
||||
const normalized = await this.normalizeTeacherForSchedule({
|
||||
...dto,
|
||||
|
||||
43
apps/server/src/students/students.lifecycle.spec.ts
Normal file
43
apps/server/src/students/students.lifecycle.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { StudentsService } from './students.service';
|
||||
|
||||
function createService(repo: Record<string, jest.Mock>, organizationRepo = {}) {
|
||||
return new StudentsService(
|
||||
repo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
organizationRepo as never,
|
||||
);
|
||||
}
|
||||
|
||||
describe('StudentsService — archive lifecycle boundaries', () => {
|
||||
it('rejects archiving an already archived student', async () => {
|
||||
const repo = { findOne: jest.fn().mockResolvedValue({ id: 1, status: 'archived' }) };
|
||||
await expect(createService(repo).remove(1)).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects restoring a student that is not archived', async () => {
|
||||
const repo = { findOne: jest.fn().mockResolvedValue({ id: 1, status: 'active' }) };
|
||||
await expect(createService(repo).restore(1)).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects an empty batch archive', async () => {
|
||||
await expect(createService({}).batchRemove([])).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects creating a student under a missing or archived organization', async () => {
|
||||
const repo = { create: jest.fn(), save: jest.fn() };
|
||||
const organizationRepo = { findOne: jest.fn().mockResolvedValue(null) };
|
||||
await expect(
|
||||
createService(repo, organizationRepo).create({ name: '张三', organizationId: 9 }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(repo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns not found for a missing student', async () => {
|
||||
const repo = { findOne: jest.fn().mockResolvedValue(null) };
|
||||
await expect(createService(repo).findOne(404)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
35
apps/server/src/sync/dto/schedule-sync.dto.spec.ts
Normal file
35
apps/server/src/sync/dto/schedule-sync.dto.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'reflect-metadata';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { ScheduleSyncQueryDto } from './schedule-sync.dto';
|
||||
|
||||
describe('ScheduleSyncQueryDto', () => {
|
||||
it('transforms valid query strings', async () => {
|
||||
const dto = plainToInstance(ScheduleSyncQueryDto, {
|
||||
dateFrom: '2026-07-13',
|
||||
days: '7',
|
||||
attendanceMachineOnly: 'true',
|
||||
});
|
||||
expect(await validate(dto)).toEqual([]);
|
||||
expect(dto).toMatchObject({ days: 7, attendanceMachineOnly: true });
|
||||
});
|
||||
|
||||
it.each(['0', '-1', '91', '7.5', 'abc'])('rejects invalid sync days %s', async (days) => {
|
||||
const dto = plainToInstance(ScheduleSyncQueryDto, { days });
|
||||
expect((await validate(dto)).some((error) => error.property === 'days')).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['not-a-date', '2026-02-31', '2026-07-13T00:00:00Z'])(
|
||||
'rejects invalid or non-date-only start date %s',
|
||||
async (dateFrom) => {
|
||||
const dto = plainToInstance(ScheduleSyncQueryDto, { dateFrom });
|
||||
expect((await validate(dto)).some((error) => error.property === 'dateFrom')).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it('treats non-true boolean strings as false', async () => {
|
||||
const dto = plainToInstance(ScheduleSyncQueryDto, { attendanceMachineOnly: 'false' });
|
||||
expect(await validate(dto)).toEqual([]);
|
||||
expect(dto.attendanceMachineOnly).toBe(false);
|
||||
});
|
||||
});
|
||||
21
apps/server/src/sync/dto/schedule-sync.dto.ts
Normal file
21
apps/server/src/sync/dto/schedule-sync.dto.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { IsBoolean, IsISO8601, IsInt, IsOptional, Matches, Max, Min } from 'class-validator';
|
||||
|
||||
export class ScheduleSyncQueryDto {
|
||||
@IsOptional()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@IsISO8601({ strict: true })
|
||||
dateFrom?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(90)
|
||||
days: number = 30;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === true || value === 'true')
|
||||
@IsBoolean()
|
||||
attendanceMachineOnly: boolean = false;
|
||||
}
|
||||
@@ -549,3 +549,63 @@ describe('ScheduleSyncService — multiple lessons per student per day', () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('ScheduleSyncService — date and overnight boundaries', () => {
|
||||
const createService = (schedule: ClassSchedule) => {
|
||||
const scheduleUsers = jest.fn().mockResolvedValue(undefined);
|
||||
const upsertShift = jest.fn().mockResolvedValue(501);
|
||||
const service = new ScheduleSyncService(
|
||||
{ find: jest.fn().mockResolvedValue([schedule]) } as never,
|
||||
{ find: jest.fn().mockResolvedValue([{ classId: 10, studentId: 20, status: 'active' }]) } as never,
|
||||
{ find: jest.fn().mockResolvedValue([{ studentId: 20, dingUserId: 'student-1' }]) } as never,
|
||||
{ find: jest.fn().mockResolvedValue([{ id: 10, name: '边界班' }]) } as never,
|
||||
{
|
||||
queryShifts: jest.fn().mockResolvedValue([]), upsertShift,
|
||||
queryAttendanceGroups: jest.fn().mockResolvedValue([
|
||||
{ group_id: 88, group_name: '排课_边界班', type: 'TURN', member_count: 1 },
|
||||
]),
|
||||
updateAttendanceGroup: jest.fn().mockResolvedValue(undefined),
|
||||
createAttendanceGroup: jest.fn(), scheduleUsers,
|
||||
} as never,
|
||||
);
|
||||
return { service, scheduleUsers, upsertShift };
|
||||
};
|
||||
|
||||
it('syncs exactly the requested number of calendar days', async () => {
|
||||
const { service, scheduleUsers } = createService({
|
||||
id: 1, classId: 10, weekDay: 1, startTime: '09:00', endTime: '10:00',
|
||||
startDate: '2026-07-13', endDate: '2026-07-20', status: 'active',
|
||||
} as ClassSchedule);
|
||||
await service.syncAll('2026-07-13', 7);
|
||||
expect(scheduleUsers.mock.calls.flatMap((call) => call[1])).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('marks an overnight lesson off-duty time as next-day', async () => {
|
||||
const { service, upsertShift } = createService({
|
||||
id: 1, classId: 10, weekDay: 1, startTime: '22:00', endTime: '01:00',
|
||||
startDate: '2026-07-13', endDate: '2026-07-13', status: 'active',
|
||||
} as ClassSchedule);
|
||||
await service.syncAll('2026-07-13', 1);
|
||||
expect(upsertShift).toHaveBeenCalledWith(expect.objectContaining({
|
||||
sections: [expect.objectContaining({ times: expect.arrayContaining([
|
||||
expect.objectContaining({ check_type: 'OnDuty', across: 0 }),
|
||||
expect.objectContaining({ check_type: 'OffDuty', across: 1 }),
|
||||
]) })],
|
||||
}));
|
||||
});
|
||||
|
||||
it('does not shift the requested date when the server timezone is behind China', async () => {
|
||||
const originalTz = process.env.TZ;
|
||||
process.env.TZ = 'America/Los_Angeles';
|
||||
try {
|
||||
const { service, scheduleUsers } = createService({
|
||||
id: 1, classId: 10, weekDay: 1, startTime: '09:00', endTime: '10:00',
|
||||
startDate: '2026-07-13', endDate: '2026-07-13', status: 'active',
|
||||
} as ClassSchedule);
|
||||
await service.syncAll('2026-07-13', 1);
|
||||
expect(scheduleUsers).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
process.env.TZ = originalTz;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,7 +94,8 @@ export class ScheduleSyncService {
|
||||
attendanceMachineOnly = false,
|
||||
): Promise<ScheduleSyncResult> {
|
||||
const startDate = dateFrom || new Date().toISOString().slice(0, 10);
|
||||
const endDate = this.addDays(startDate, days);
|
||||
const normalizedDays = Number.isFinite(days) ? Math.max(1, Math.floor(days)) : 30;
|
||||
const endDate = this.addDays(startDate, normalizedDays - 1);
|
||||
|
||||
const empty: ScheduleSyncResult = {
|
||||
scheduleCount: 0,
|
||||
@@ -170,7 +171,7 @@ export class ScheduleSyncService {
|
||||
},
|
||||
{
|
||||
check_type: 'OffDuty' as const,
|
||||
across: 0,
|
||||
across: this.toMinutes(period.endTime) <= this.toMinutes(period.startTime) ? 1 : 0,
|
||||
check_time: `1970-01-01 ${period.endTime}:00`,
|
||||
free_check: false,
|
||||
},
|
||||
@@ -377,12 +378,12 @@ export class ScheduleSyncService {
|
||||
syncTo: string,
|
||||
): DailySchedulePlan[] {
|
||||
const periodMapByClassDate = new Map<string, Map<string, DailySchedulePeriod>>();
|
||||
const fromDate = new Date(syncFrom);
|
||||
const toDate = new Date(syncTo);
|
||||
const fromDate = new Date(`${syncFrom}T00:00:00.000Z`);
|
||||
const toDate = new Date(`${syncTo}T00:00:00.000Z`);
|
||||
|
||||
for (let date = new Date(fromDate); date <= toDate; date.setDate(date.getDate() + 1)) {
|
||||
for (let date = new Date(fromDate); date <= toDate; date.setUTCDate(date.getUTCDate() + 1)) {
|
||||
const dateStr = date.toISOString().slice(0, 10);
|
||||
const weekDay = date.getDay() === 0 ? 7 : date.getDay();
|
||||
const weekDay = date.getUTCDay() === 0 ? 7 : date.getUTCDay();
|
||||
|
||||
for (const schedule of schedules) {
|
||||
if (schedule.classId == null || schedule.weekDay !== weekDay) continue;
|
||||
@@ -450,18 +451,21 @@ export class ScheduleSyncService {
|
||||
return items;
|
||||
}
|
||||
|
||||
private toMinutes(time: string): number {
|
||||
const [hour, minute] = time.split(':').map(Number);
|
||||
return hour * 60 + minute;
|
||||
}
|
||||
|
||||
private minutesBetween(startTime: string, endTime: string): number {
|
||||
const [startHour, startMinute] = startTime.split(':').map(Number);
|
||||
const [endHour, endMinute] = endTime.split(':').map(Number);
|
||||
const start = startHour * 60 + startMinute;
|
||||
let end = endHour * 60 + endMinute;
|
||||
const start = this.toMinutes(startTime);
|
||||
let end = this.toMinutes(endTime);
|
||||
if (end <= start) end += 24 * 60;
|
||||
return end - start;
|
||||
}
|
||||
|
||||
private addDays(dateStr: string, days: number): string {
|
||||
const d = new Date(dateStr);
|
||||
d.setDate(d.getDate() + days);
|
||||
const d = new Date(`${dateStr}T00:00:00.000Z`);
|
||||
d.setUTCDate(d.getUTCDate() + days);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SyncController } from './sync.controller';
|
||||
import { ScheduleSyncQueryDto } from './dto/schedule-sync.dto';
|
||||
|
||||
describe('SyncController — schedule sync options', () => {
|
||||
it('forwards the attendance-machine-only option', async () => {
|
||||
@@ -7,11 +8,11 @@ describe('SyncController — schedule sync options', () => {
|
||||
};
|
||||
const controller = new SyncController(syncService as never);
|
||||
|
||||
await (controller.syncSchedule as unknown as (
|
||||
dateFrom?: string,
|
||||
days?: string,
|
||||
attendanceMachineOnly?: string,
|
||||
) => Promise<unknown>)('2026-07-10', '30', 'true');
|
||||
await controller.syncSchedule(Object.assign(new ScheduleSyncQueryDto(), {
|
||||
dateFrom: '2026-07-10',
|
||||
days: 30,
|
||||
attendanceMachineOnly: true,
|
||||
}));
|
||||
|
||||
expect(syncService.syncScheduleToDingTalk).toHaveBeenCalledWith(
|
||||
'2026-07-10',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
import { SyncService } from './sync.service';
|
||||
import type { SyncPlatform } from '../entities/sync-log.entity';
|
||||
import { ScheduleSyncQueryDto } from './dto/schedule-sync.dto';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('sync')
|
||||
@@ -79,15 +80,11 @@ export class SyncController {
|
||||
/** 触发排班同步到钉钉考勤排班 */
|
||||
@Post('schedule/sync')
|
||||
@RequirePermission('sync:trigger')
|
||||
async syncSchedule(
|
||||
@Query('dateFrom') dateFrom?: string,
|
||||
@Query('days') days?: string,
|
||||
@Query('attendanceMachineOnly') attendanceMachineOnly?: string,
|
||||
) {
|
||||
async syncSchedule(@Query() query: ScheduleSyncQueryDto) {
|
||||
const result = await this.syncService.syncScheduleToDingTalk(
|
||||
dateFrom,
|
||||
days ? parseInt(days, 10) : 30,
|
||||
attendanceMachineOnly === 'true',
|
||||
query.dateFrom,
|
||||
query.days,
|
||||
query.attendanceMachineOnly,
|
||||
);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
@@ -50,3 +50,70 @@ describe('WalletsService payment rules', () => {
|
||||
expect(ctx.saved.some((row) => row.type === 'bill_refund' && Number(row.amount) === 40)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WalletsService financial boundaries', () => {
|
||||
it('caps a debit at total minus already paid even when outstandingAmount is stale', async () => {
|
||||
const ctx = manager(50);
|
||||
const service = new WalletsService({} as any, {} as any, {} as any, {} as any);
|
||||
const bill = {
|
||||
id: 10,
|
||||
studentId: 10,
|
||||
totalAmount: 100,
|
||||
paidAmount: 90,
|
||||
outstandingAmount: 100,
|
||||
status: 'partially_paid',
|
||||
} as Bill;
|
||||
|
||||
await service.debitBill(ctx.value as any, bill);
|
||||
|
||||
expect(bill).toMatchObject({ paidAmount: 100, outstandingAmount: 0, status: 'paid' });
|
||||
expect(ctx.wallet.balance).toBe(40);
|
||||
expect(ctx.saved.some((row) => row.type === 'bill_payment' && row.amount === -10)).toBe(true);
|
||||
});
|
||||
|
||||
it('caps a refund at the bill total when paidAmount is corrupt', async () => {
|
||||
const ctx = manager(10);
|
||||
const service = new WalletsService({} as any, {} as any, {} as any, {} as any);
|
||||
const bill = {
|
||||
id: 11,
|
||||
studentId: 10,
|
||||
totalAmount: 100,
|
||||
paidAmount: 150,
|
||||
outstandingAmount: 0,
|
||||
status: 'paid',
|
||||
} as Bill;
|
||||
|
||||
await service.refundBill(ctx.value as any, bill, '冲正');
|
||||
|
||||
expect(ctx.wallet.balance).toBe(110);
|
||||
expect(ctx.saved.some((row) => row.type === 'bill_refund' && row.amount === 100)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not issue a second refund for an already cancelled bill', async () => {
|
||||
const ctx = manager(10);
|
||||
const service = new WalletsService({} as any, {} as any, {} as any, {} as any);
|
||||
const bill = {
|
||||
id: 12,
|
||||
studentId: 10,
|
||||
totalAmount: 100,
|
||||
paidAmount: 100,
|
||||
outstandingAmount: 0,
|
||||
status: 'cancelled',
|
||||
} as Bill;
|
||||
|
||||
await service.refundBill(ctx.value as any, bill, '重复取消');
|
||||
|
||||
expect(ctx.wallet.balance).toBe(10);
|
||||
expect(ctx.saved).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects an amount that rounds to zero before opening a transaction', async () => {
|
||||
const dataSource = { transaction: jest.fn() };
|
||||
const service = new WalletsService({} as any, {} as any, {} as any, dataSource as any);
|
||||
|
||||
await expect(service.changeBalance({ studentId: 1, amount: 0.004, type: 'adjustment' })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,12 +61,17 @@ export class WalletsService {
|
||||
}
|
||||
|
||||
async changeBalance(dto: ChangeWalletBalanceDto, recordedBy?: number) {
|
||||
if (dto.type === 'recharge' && dto.amount <= 0) throw new BadRequestException('充值金额必须大于 0');
|
||||
const amount = money(dto.amount);
|
||||
if (!Number.isFinite(dto.amount) || Math.abs(dto.amount * 100 - Math.round(dto.amount * 100)) > 1e-8) {
|
||||
throw new BadRequestException('调账金额最多保留两位小数');
|
||||
}
|
||||
if (amount === 0) throw new BadRequestException('调账金额不能为 0');
|
||||
if (dto.type === 'recharge' && amount <= 0) throw new BadRequestException('充值金额必须大于 0');
|
||||
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
|
||||
if (!student) throw new NotFoundException('学生不存在');
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const wallet = await this.getOrCreateWallet(manager, dto.studentId);
|
||||
const nextBalance = money(Number(wallet.balance) + dto.amount);
|
||||
const nextBalance = money(Number(wallet.balance) + amount);
|
||||
if (nextBalance < 0) throw new BadRequestException('调账后余额不能小于 0');
|
||||
wallet.balance = nextBalance;
|
||||
await manager.save(wallet);
|
||||
@@ -75,29 +80,43 @@ export class WalletsService {
|
||||
studentId: dto.studentId,
|
||||
billId: null,
|
||||
type: dto.type,
|
||||
amount: money(dto.amount),
|
||||
amount,
|
||||
balanceAfter: nextBalance,
|
||||
description: dto.description || (dto.type === 'recharge' ? '财务充值' : '余额调账'),
|
||||
recordedBy: recordedBy || null,
|
||||
}),
|
||||
);
|
||||
const payments = dto.amount > 0 ? await this.settleOutstandingBills(manager, dto.studentId, recordedBy) : [];
|
||||
const payments = amount > 0 ? await this.settleOutstandingBills(manager, dto.studentId, recordedBy) : [];
|
||||
const finalWallet = await manager.findOneByOrFail(StudentWallet, { studentId: dto.studentId });
|
||||
return { wallet: finalWallet, payments };
|
||||
});
|
||||
}
|
||||
|
||||
async debitBill(manager: EntityManager, bill: Bill, recordedBy?: number) {
|
||||
if (bill.status === 'cancelled' || money(bill.outstandingAmount) <= 0) return bill;
|
||||
const wallet = await this.getOrCreateWallet(manager, bill.studentId);
|
||||
const amount = money(Math.min(Number(wallet.balance), Number(bill.outstandingAmount)));
|
||||
if (amount <= 0) {
|
||||
bill.status = money(bill.paidAmount) > 0 ? 'partially_paid' : 'unpaid';
|
||||
if (bill.status === 'cancelled') return bill;
|
||||
|
||||
const total = money(bill.totalAmount);
|
||||
const paid = Math.max(0, Math.min(money(bill.paidAmount), total));
|
||||
const remaining = money(Math.max(0, total - paid));
|
||||
if (remaining <= 0) {
|
||||
bill.paidAmount = total;
|
||||
bill.outstandingAmount = 0;
|
||||
bill.status = 'paid';
|
||||
return manager.save(bill);
|
||||
}
|
||||
|
||||
const wallet = await this.getOrCreateWallet(manager, bill.studentId);
|
||||
const amount = money(Math.min(Math.max(0, money(wallet.balance)), remaining));
|
||||
if (amount <= 0) {
|
||||
bill.paidAmount = paid;
|
||||
bill.outstandingAmount = remaining;
|
||||
bill.status = paid > 0 ? 'partially_paid' : 'unpaid';
|
||||
return manager.save(bill);
|
||||
}
|
||||
|
||||
wallet.balance = money(Number(wallet.balance) - amount);
|
||||
bill.paidAmount = money(Number(bill.paidAmount) + amount);
|
||||
bill.outstandingAmount = money(Number(bill.totalAmount) - Number(bill.paidAmount));
|
||||
bill.paidAmount = money(paid + amount);
|
||||
bill.outstandingAmount = money(Math.max(0, total - Number(bill.paidAmount)));
|
||||
bill.status = bill.outstandingAmount <= 0 ? 'paid' : 'partially_paid';
|
||||
await manager.save(wallet);
|
||||
await manager.save(bill);
|
||||
@@ -109,14 +128,16 @@ export class WalletsService {
|
||||
amount: -amount,
|
||||
balanceAfter: wallet.balance,
|
||||
description: `账单 #${bill.id} 自动扣款`,
|
||||
recordedBy: recordedBy || null,
|
||||
recordedBy: recordedBy ?? null,
|
||||
}),
|
||||
);
|
||||
return bill;
|
||||
}
|
||||
|
||||
async refundBill(manager: EntityManager, bill: Bill, reason: string, recordedBy?: number) {
|
||||
const paid = money(bill.paidAmount);
|
||||
if (bill.status === 'cancelled') return bill;
|
||||
|
||||
const paid = Math.max(0, Math.min(money(bill.paidAmount), money(bill.totalAmount)));
|
||||
if (paid > 0) {
|
||||
const wallet = await this.getOrCreateWallet(manager, bill.studentId);
|
||||
wallet.balance = money(Number(wallet.balance) + paid);
|
||||
@@ -129,7 +150,7 @@ export class WalletsService {
|
||||
amount: paid,
|
||||
balanceAfter: wallet.balance,
|
||||
description: `取消账单 #${bill.id} 冲正:${reason}`,
|
||||
recordedBy: recordedBy || null,
|
||||
recordedBy: recordedBy ?? null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user