feat: refine deposit room type workflows

This commit is contained in:
2026-07-17 17:36:04 +08:00
parent a5bda6f093
commit b3d0bafc22
11 changed files with 724 additions and 140 deletions

View File

@@ -16,8 +16,8 @@ import { Repository } from 'typeorm';
import { Student } from '../entities/student.entity';
import { DepositsService } from './deposits.service';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationType } from '../entities/notification.entity';
import {
BatchCreateDepositDto,
CreateDepositDto,
CreateDepositInstallmentDto,
RefundDepositDto,
@@ -44,6 +44,13 @@ export class DepositsController {
return this.service.getStudentLookups();
}
@Get('eligible-students')
@RequirePermission('deposit:view')
getEligibleStudents(@Query('roomType') roomType?: string) {
return this.service.getEligibleStudents(roomType || undefined);
}
@Get()
@RequirePermission('deposit:view')
findAll(
@@ -99,6 +106,25 @@ export class DepositsController {
return result;
}
@Post('batch')
@RequirePermission('deposit:create')
async batchCreate(@Body() dto: BatchCreateDepositDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchCreate(dto, req.user?.id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '押金管理',
action: '批量收取押金',
targetType: 'deposit',
detail: `批量收取${result.count}人,每人¥${result.amount}${dto.roomType ? `,房型:${dto.roomType}` : ''}${dto.notes ? `,备注:${dto.notes}` : ''}`,
ipAddress,
userAgent,
});
return result;
}
@Post(':id/installments')
@RequirePermission('deposit:edit')
async addInstallment(

View File

@@ -3,13 +3,14 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { Student } from '../entities/student.entity';
import { Deposit } from '../entities/deposit.entity';
import { DepositInstallment } from '../entities/deposit-installment.entity';
import { Occupancy } from '../entities/occupancy.entity';
import { DepositsService } from './deposits.service';
import { DepositsController } from './deposits.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [TypeOrmModule.forFeature([Deposit, DepositInstallment, Student]), OperationLogsModule, NotificationsModule],
imports: [TypeOrmModule.forFeature([Deposit, DepositInstallment, Student, Occupancy]), OperationLogsModule, NotificationsModule],
controllers: [DepositsController],
providers: [DepositsService],
exports: [DepositsService],

View File

@@ -0,0 +1,98 @@
import { BadRequestException } from '@nestjs/common';
import { DepositsService } from './deposits.service';
const createQb = (rows: unknown[] = []) => ({
innerJoin: jest.fn().mockReturnThis(),
leftJoin: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
addSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
addOrderBy: jest.fn().mockReturnThis(),
getRawMany: jest.fn().mockResolvedValue(rows),
});
function makeService(rows: unknown[] = []) {
const qb = createQb(rows);
const repo = {
findOne: jest.fn(),
create: jest.fn((value) => value),
save: jest.fn(async (value) => value),
};
const studentRepo = { findOne: jest.fn(async ({ where }: any) => ({ id: where.id })) };
const occupancyRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };
const service = new DepositsService(repo as never, {} as never, studentRepo as never, occupancyRepo as never);
return { service, qb, repo, studentRepo };
}
describe('DepositsService room-type deposits', () => {
it('filters current occupants by room type with capacity fallback', async () => {
const { service, qb } = makeService([
{
studentId: 1,
studentName: '张三',
studentNo: 'S1',
roomId: 8,
roomNumber: '401',
building: 'A',
roomType: null,
capacity: 4,
depositAmount: null,
},
]);
await expect(service.getEligibleStudents('四人间')).resolves.toEqual([
{
studentId: 1,
studentName: '张三',
studentNo: 'S1',
roomId: 8,
roomNumber: '401',
building: 'A',
roomType: '四人间',
capacity: 4,
depositAmount: 0,
},
]);
expect(qb.where).toHaveBeenCalledWith('o.status = :activeStatus', { activeStatus: 'active' });
expect(qb.andWhere).toHaveBeenCalledWith('o.checkOutDate IS NULL');
expect(qb.andWhere).toHaveBeenCalledWith(
'(room.roomType = :roomType OR ((room.roomType IS NULL OR room.roomType = :emptyRoomType) AND room.capacity = :fallbackCapacity))',
{ roomType: '四人间', emptyRoomType: '', fallbackCapacity: 4 },
);
});
it('creates or accumulates deposits for a batch of selected students', async () => {
const { service, repo } = makeService();
const existing = { id: 1, studentId: 2, amount: 50, status: 'paid' };
repo.findOne.mockImplementation(async ({ where }: any) => {
if (where.id) return existing;
if (where.studentId === 2) return existing;
return null;
});
const result = await service.batchCreate({
studentIds: [2, 3, 3],
amount: 100,
paidDate: '2026-07-17',
notes: '四人间押金',
}, 9);
expect(result.count).toBe(2);
expect(existing.amount).toBe(150);
expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ studentId: 3, amount: 100 }));
expect(repo.save).toHaveBeenCalledTimes(2);
});
it('rejects invalid batch amounts', async () => {
const { service, repo } = makeService();
await expect(service.batchCreate({
studentIds: [1],
amount: 0.004,
paidDate: '2026-07-17',
})).rejects.toBeInstanceOf(BadRequestException);
expect(repo.save).not.toHaveBeenCalled();
});
});

View File

@@ -4,11 +4,38 @@ import { Repository } from 'typeorm';
import { Deposit } from '../entities/deposit.entity';
import { Student } from '../entities/student.entity';
import { DepositInstallment } from '../entities/deposit-installment.entity';
import { Occupancy } from '../entities/occupancy.entity';
import { CreateDepositDto, RefundDepositDto } from './dto/deposit.dto';
import { BatchCreateDepositDto, CreateDepositDto, RefundDepositDto } from './dto/deposit.dto';
const money = (value: number | string | null | undefined) => Number(Number(value || 0).toFixed(2));
const capacityRoomTypeText: Record<number, string> = {
1: '单人间',
2: '二人间',
3: '三人间',
4: '四人间',
5: '五人间',
6: '六人间',
8: '八人间',
};
const normalizeRoomType = (roomType?: string | null, capacity?: number | string | null) => {
const trimmed = roomType?.trim();
if (trimmed) return trimmed;
const normalizedCapacity = Number(capacity);
return capacityRoomTypeText[normalizedCapacity] || (normalizedCapacity > 0 ? `${normalizedCapacity}人间` : '');
};
const roomTypeCapacity = (roomType?: string) => {
const text = roomType?.trim();
if (!text) return undefined;
const knownCapacity = Object.entries(capacityRoomTypeText).find(([, label]) => label === text);
if (knownCapacity) return Number(knownCapacity[0]);
const match = text.match(/^(\d+)人间$/);
return match ? Number(match[1]) : undefined;
};
@Injectable()
export class DepositsService {
@@ -18,6 +45,8 @@ export class DepositsService {
private installmentRepo: Repository<DepositInstallment>,
@InjectRepository(Student)
private studentRepo: Repository<Student>,
@InjectRepository(Occupancy)
private occupancyRepo?: Repository<Occupancy>,
) {}
async getStudentLookups() {
@@ -28,6 +57,78 @@ export class DepositsService {
});
}
async getEligibleStudents(roomType?: string) {
const trimmedRoomType = roomType?.trim();
const fallbackCapacity = roomTypeCapacity(trimmedRoomType);
const qb = this.occupancyRepo!
.createQueryBuilder('o')
.innerJoin('o.student', 'student')
.innerJoin('o.room', 'room')
.leftJoin(Deposit, 'deposit', 'deposit.student_id = student.id AND deposit.status != :archived', {
archived: 'archived',
})
.select('student.id', 'studentId')
.addSelect('student.name', 'studentName')
.addSelect('student.studentNo', 'studentNo')
.addSelect('room.id', 'roomId')
.addSelect('room.roomNumber', 'roomNumber')
.addSelect('room.building', 'building')
.addSelect('room.roomType', 'roomType')
.addSelect('room.capacity', 'capacity')
.addSelect('deposit.amount', 'depositAmount')
.where('o.status = :activeStatus', { activeStatus: 'active' })
.andWhere('o.checkOutDate IS NULL')
.andWhere('student.status = :studentStatus', { studentStatus: 'active' })
.orderBy('room.building', 'ASC')
.addOrderBy('room.roomNumber', 'ASC')
.addOrderBy('student.name', 'ASC');
if (trimmedRoomType) {
if (fallbackCapacity) {
qb.andWhere(
'(room.roomType = :roomType OR ((room.roomType IS NULL OR room.roomType = :emptyRoomType) AND room.capacity = :fallbackCapacity))',
{ roomType: trimmedRoomType, emptyRoomType: '', fallbackCapacity },
);
} else {
qb.andWhere('room.roomType = :roomType', { roomType: trimmedRoomType });
}
}
const rows = await qb.getRawMany();
return rows.map((row) => ({
studentId: Number(row.studentId),
studentName: row.studentName,
studentNo: row.studentNo ?? null,
roomId: Number(row.roomId),
roomNumber: row.roomNumber,
building: row.building ?? null,
roomType: normalizeRoomType(row.roomType, row.capacity),
capacity: Number(row.capacity),
depositAmount: money(row.depositAmount),
}));
}
async batchCreate(dto: BatchCreateDepositDto, userId?: number) {
const studentIds = [...new Set(dto.studentIds)];
if (studentIds.length === 0) throw new BadRequestException('请选择学生');
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 results: Deposit[] = [];
for (const studentId of studentIds) {
results.push(await this.create({
studentId,
amount,
paidDate: dto.paidDate,
notes: dto.notes,
}, userId));
}
return { count: results.length, amount, results };
}
async findAll(query?: { studentId?: number; status?: string }) {
const qb = this.repo
.createQueryBuilder('d')

View File

@@ -1,4 +1,4 @@
import { IsDateString, IsIn, IsInt, IsNumber, IsString, IsOptional, Min } from 'class-validator';
import { ArrayNotEmpty, IsArray, IsDateString, IsIn, IsInt, IsNumber, IsString, IsOptional, Min } from 'class-validator';
export class CreateDepositDto {
@IsInt()
@@ -16,6 +16,28 @@ export class CreateDepositDto {
notes?: string;
}
export class BatchCreateDepositDto {
@IsArray()
@ArrayNotEmpty()
@IsInt({ each: true })
studentIds: number[];
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0.01)
amount: number;
@IsDateString()
paidDate: string;
@IsOptional()
@IsString()
notes?: string;
@IsOptional()
@IsString()
roomType?: string;
}
export class RefundDepositDto {
@IsDateString()
refundDate: string;