feat: 重构各业务模块管理页面与服务
This commit is contained in:
@@ -25,7 +25,7 @@ import {
|
||||
} from './dto/deposit.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import { extractRequestInfo } from '../common/request-utils';
|
||||
import { logAudit } from '../common/with-audit-log';
|
||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -78,31 +78,11 @@ export class DepositsController {
|
||||
@Post()
|
||||
@RequirePermission('deposit:create')
|
||||
async create(@Body() dto: CreateDepositDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.create(dto, req.user?.id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '押金管理',
|
||||
action: '收取押金',
|
||||
targetId: result.id,
|
||||
targetType: 'deposit',
|
||||
detail: `学生${dto.studentId} ¥${dto.amount}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
await logAudit(this.logService, req, {
|
||||
module: '押金管理', action: '收取押金', targetId: result.id, targetType: 'deposit', detail: `学生${dto.studentId} ¥${dto.amount}`,
|
||||
});
|
||||
// Send deposit_due notification
|
||||
try {
|
||||
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
|
||||
if (student?.userId) {
|
||||
void this.notificationsService.create({
|
||||
recipientIds: [student.userId],
|
||||
type: 'deposit_due',
|
||||
title: '押金待缴',
|
||||
content: `您有一笔押金待缴纳,金额: ¥${dto.amount}`,
|
||||
});
|
||||
}
|
||||
} catch (_) { /* don't block response */ }
|
||||
await this.notifyDeposit(dto.studentId, 'deposit_due', '押金待缴', `您有一笔押金待缴纳,金额: ¥${dto.amount}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -110,17 +90,9 @@ export class DepositsController {
|
||||
@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,
|
||||
await logAudit(this.logService, req, {
|
||||
module: '押金管理', action: '批量收取押金', targetType: 'deposit', detail: `批量收取${result.count}人,每人¥${result.amount}${dto.roomType ? `,房型:${dto.roomType}` : ''}${dto.notes ? `,备注:${dto.notes}` : ''}`,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
@@ -132,18 +104,9 @@ export class DepositsController {
|
||||
@Body() body: CreateDepositInstallmentDto,
|
||||
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.addInstallment(id, body.amount, body.dueDate);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '押金管理',
|
||||
action: '新增分期',
|
||||
targetId: result.id,
|
||||
targetType: 'deposit-installment',
|
||||
detail: `押金${id} 新增分期 ¥${result.amount}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
await logAudit(this.logService, req, {
|
||||
module: '押金管理', action: '新增分期', targetId: result.id, targetType: 'deposit-installment', detail: `押金${id} 新增分期 ¥${result.amount}`,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
@@ -155,18 +118,9 @@ export class DepositsController {
|
||||
@Body() body: UpdateDepositInstallmentDto,
|
||||
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.updateInstallment(installmentId, body);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '押金管理',
|
||||
action: '更新分期',
|
||||
targetId: installmentId,
|
||||
targetType: 'deposit-installment',
|
||||
detail: `更新分期${installmentId}, 状态:${result.status ?? '-'}, 实付日:${result.paidDate ?? '-'}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
await logAudit(this.logService, req, {
|
||||
module: '押金管理', action: '更新分期', targetId: installmentId, targetType: 'deposit-installment', detail: `更新分期${installmentId}, 状态:${result.status ?? '-'}, 实付日:${result.paidDate ?? '-'}`,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
@@ -177,18 +131,9 @@ export class DepositsController {
|
||||
@Param('installmentId', ParseIntPipe) installmentId: number,
|
||||
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.deleteInstallment(installmentId);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '押金管理',
|
||||
action: '归档分期',
|
||||
targetId: installmentId,
|
||||
targetType: 'deposit-installment',
|
||||
detail: `归档分期${installmentId}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
await logAudit(this.logService, req, {
|
||||
module: '押金管理', action: '归档分期', targetId: installmentId, targetType: 'deposit-installment', detail: `归档分期${installmentId}`,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
@@ -196,48 +141,46 @@ export class DepositsController {
|
||||
@Put(':id/refund')
|
||||
@RequirePermission('deposit:refund')
|
||||
async refund(@Param('id', ParseIntPipe) id: number, @Body() dto: RefundDepositDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.refund(id, dto, req.user?.id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '押金管理',
|
||||
action: '退还押金',
|
||||
targetId: id,
|
||||
targetType: 'deposit',
|
||||
detail: `退还全部可用押金 ¥${result.refundAmount}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
await logAudit(this.logService, req, {
|
||||
module: '押金管理', action: '退还押金', targetId: id, targetType: 'deposit', detail: `退还全部可用押金 ¥${result.refundAmount}`,
|
||||
});
|
||||
// Send deposit_refunded notification
|
||||
try {
|
||||
const student = await this.studentRepo.findOne({ where: { id: result.studentId } });
|
||||
if (student?.userId) {
|
||||
void this.notificationsService.create({
|
||||
recipientIds: [student.userId],
|
||||
type: 'deposit_refunded',
|
||||
title: '押金已退还',
|
||||
content: `您的剩余押金已全部退还,金额: ¥${result.refundAmount}`,
|
||||
});
|
||||
}
|
||||
} catch (_) { /* don't block response */ }
|
||||
await this.notifyDeposit(result.studentId, 'deposit_refunded', '押金已退还', `您的剩余押金已全部退还,金额: ¥${result.refundAmount}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async notifyDeposit(
|
||||
studentId: number,
|
||||
type: 'deposit_due' | 'deposit_refunded',
|
||||
title: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const student = await this.studentRepo.findOne({ where: { id: studentId } });
|
||||
if (student?.userId) {
|
||||
void this.notificationsService.create({ recipientIds: [student.userId], type, title, content });
|
||||
}
|
||||
} catch {
|
||||
// 通知失败不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('deposit:delete')
|
||||
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.remove(id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '押金管理',
|
||||
action: '归档押金记录',
|
||||
targetId: id,
|
||||
targetType: 'deposit',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
await logAudit(this.logService, req, {
|
||||
module: '押金管理', action: '归档押金记录', targetId: id, targetType: 'deposit',
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Delete(':id/permanent')
|
||||
@RequirePermission('deposit:purge')
|
||||
async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||
const result = await this.service.purge(id);
|
||||
await logAudit(this.logService, req, {
|
||||
module: '押金管理', action: '永久删除押金', targetId: id, targetType: 'deposit', detail: '物理删除,不可恢复',
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
28
apps/server/src/deposits/deposits.purge.controller.spec.ts
Normal file
28
apps/server/src/deposits/deposits.purge.controller.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import 'reflect-metadata';
|
||||
import { PERMISSION_KEY } from '../auth/decorators/permission.decorator';
|
||||
import { DepositsController } from './deposits.controller';
|
||||
|
||||
describe('DepositsController purge route', () => {
|
||||
it('requires deposit:purge on permanent delete route', () => {
|
||||
expect(Reflect.getMetadata(PERMISSION_KEY, DepositsController.prototype.purge)).toEqual([
|
||||
'deposit:purge',
|
||||
]);
|
||||
});
|
||||
|
||||
it('writes permanent delete audit logs', async () => {
|
||||
const service = { purge: jest.fn().mockResolvedValue({ message: '已永久删除押金(不可恢复)' }) };
|
||||
const log = jest.fn().mockResolvedValue(undefined);
|
||||
const controller = new DepositsController(
|
||||
service as never,
|
||||
{ log } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} };
|
||||
await controller.purge(1, req);
|
||||
expect(service.purge).toHaveBeenCalledWith(1);
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ module: '押金管理', action: '永久删除押金', targetId: 1 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
65
apps/server/src/deposits/deposits.purge.spec.ts
Normal file
65
apps/server/src/deposits/deposits.purge.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { DepositsService } from './deposits.service';
|
||||
|
||||
describe('DepositsService.purge', () => {
|
||||
const createService = (overrides?: { deposit?: Record<string, unknown> }) => {
|
||||
const deposit = {
|
||||
id: 1,
|
||||
studentId: 2,
|
||||
amount: 500,
|
||||
status: 'archived',
|
||||
refundAmount: null,
|
||||
deductionAmount: 0,
|
||||
...overrides?.deposit,
|
||||
};
|
||||
const repo = {
|
||||
findOne: jest.fn().mockResolvedValue(deposit),
|
||||
delete: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||
};
|
||||
const installmentRepo = { count: jest.fn().mockResolvedValue(0) };
|
||||
const service = new DepositsService(
|
||||
repo as never,
|
||||
installmentRepo as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, repo, installmentRepo };
|
||||
};
|
||||
|
||||
it('rejects deposits that are not archived', async () => {
|
||||
const { service, repo } = createService({ deposit: { status: 'paid' } });
|
||||
await expect(service.purge(1)).rejects.toThrow(
|
||||
new BadRequestException('仅已归档押金可以永久删除,请先归档'),
|
||||
);
|
||||
expect(repo.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects deposits with refund or deduction amounts', async () => {
|
||||
const withRefund = createService({ deposit: { refundAmount: 100 } });
|
||||
await expect(withRefund.service.purge(1)).rejects.toThrow(
|
||||
new BadRequestException('该押金已有退款金额,无法永久删除'),
|
||||
);
|
||||
|
||||
const withDeduction = createService({ deposit: { deductionAmount: 50 } });
|
||||
await expect(withDeduction.service.purge(1)).rejects.toThrow(
|
||||
new BadRequestException('该押金已有抵扣金额,无法永久删除'),
|
||||
);
|
||||
expect(withDeduction.repo.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects deposits with paid installments', async () => {
|
||||
const { service, installmentRepo, repo } = createService();
|
||||
installmentRepo.count.mockResolvedValue(1);
|
||||
await expect(service.purge(1)).rejects.toThrow(
|
||||
new BadRequestException('该押金存在已支付分期,无法永久删除'),
|
||||
);
|
||||
expect(repo.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes an archived deposit with no paid history', async () => {
|
||||
const { service, repo } = createService();
|
||||
await expect(service.purge(1)).resolves.toEqual({
|
||||
message: '已永久删除押金(不可恢复)',
|
||||
});
|
||||
expect(repo.delete).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
@@ -67,16 +67,21 @@ export class DepositsService {
|
||||
.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' })
|
||||
.select('student.id', 'studentId');
|
||||
const eligibleSelects = [
|
||||
['student.name', 'studentName'],
|
||||
['student.studentNo', 'studentNo'],
|
||||
['room.id', 'roomId'],
|
||||
['room.roomNumber', 'roomNumber'],
|
||||
['room.building', 'building'],
|
||||
['room.roomType', 'roomType'],
|
||||
['room.capacity', 'capacity'],
|
||||
['deposit.amount', 'depositAmount'],
|
||||
] as const;
|
||||
for (const [column, alias] of eligibleSelects) {
|
||||
qb.addSelect(column, alias);
|
||||
}
|
||||
qb.where('o.status = :activeStatus', { activeStatus: 'active' })
|
||||
.andWhere('o.checkOutDate IS NULL')
|
||||
.andWhere('student.status = :studentStatus', { studentStatus: 'active' })
|
||||
.orderBy('room.building', 'ASC')
|
||||
@@ -167,15 +172,20 @@ export class DepositsService {
|
||||
const qb = this.repo
|
||||
.createQueryBuilder('d')
|
||||
.leftJoin('d.student', 'student')
|
||||
.select('d.id', 'id')
|
||||
.addSelect('student.name', 'studentName')
|
||||
.addSelect('student.studentNo', 'studentNo')
|
||||
.addSelect('d.amount', 'amount')
|
||||
.addSelect('d.status', 'status')
|
||||
.addSelect('d.paidDate', 'paidDate')
|
||||
.addSelect('d.refundAmount', 'refundAmount')
|
||||
.addSelect('d.refundDate', 'refundDate')
|
||||
.where('d.status != :archived', { archived: 'archived' });
|
||||
.select('d.id', 'id');
|
||||
const depositSelects = [
|
||||
['student.name', 'studentName'],
|
||||
['student.studentNo', 'studentNo'],
|
||||
['d.amount', 'amount'],
|
||||
['d.status', 'status'],
|
||||
['d.paidDate', 'paidDate'],
|
||||
['d.refundAmount', 'refundAmount'],
|
||||
['d.refundDate', 'refundDate'],
|
||||
] as const;
|
||||
for (const [column, alias] of depositSelects) {
|
||||
qb.addSelect(column, alias);
|
||||
}
|
||||
qb.where('d.status != :archived', { archived: 'archived' });
|
||||
if (query?.keyword) {
|
||||
qb.andWhere(
|
||||
'(student.name LIKE :keyword OR student.studentNo LIKE :keyword)',
|
||||
@@ -226,8 +236,8 @@ export class DepositsService {
|
||||
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.refundDate = null;
|
||||
existing.refundAmount = null;
|
||||
existing.refundedBy = null;
|
||||
existing.refundedAt = null;
|
||||
if (dto.notes) existing.notes = dto.notes;
|
||||
@@ -309,6 +319,28 @@ export class DepositsService {
|
||||
return { message: '已归档' };
|
||||
}
|
||||
|
||||
async purge(id: number) {
|
||||
const deposit = await this.repo.findOne({ where: { id } });
|
||||
if (!deposit) throw new NotFoundException('押金记录不存在');
|
||||
if (deposit.status !== 'archived') {
|
||||
throw new BadRequestException('仅已归档押金可以永久删除,请先归档');
|
||||
}
|
||||
if (Number(deposit.refundAmount || 0) > 0) {
|
||||
throw new BadRequestException('该押金已有退款金额,无法永久删除');
|
||||
}
|
||||
if (Number(deposit.deductionAmount || 0) > 0) {
|
||||
throw new BadRequestException('该押金已有抵扣金额,无法永久删除');
|
||||
}
|
||||
const paidInstallments = await this.installmentRepo.count({
|
||||
where: { depositId: id, status: 'paid' },
|
||||
});
|
||||
if (paidInstallments > 0) {
|
||||
throw new BadRequestException('该押金存在已支付分期,无法永久删除');
|
||||
}
|
||||
await this.repo.delete(id);
|
||||
return { message: '已永久删除押金(不可恢复)' };
|
||||
}
|
||||
|
||||
async getStats() {
|
||||
const qb = this.repo
|
||||
.createQueryBuilder('d')
|
||||
|
||||
Reference in New Issue
Block a user