P2-14: Deposit installment tracking + refund approval flow
- Add DepositInstallment entity (id, depositId, amount, dueDate, paidDate, status, createdAt) - Add installments OneToMany relation to Deposit entity with cascade+eager - Add refund approval fields: refundStatus, refundRequestedAt, refundApprovedBy, refundApprovedAt - Add installment DTOs (CreateInstallmentDto, UpdateInstallmentDto) - Add refund approval DTOs (ApproveRefundDto, CreateDepositWithInstallmentsDto) - Service: add/create/update/delete installments, requestRefund, approveRefund, findPendingRefunds - Controller: GET deposits/:id, GET pending-refunds, POST :id/installments, PUT installments/:id, DELETE installments/:id, POST :id/request-refund, PUT :id/approve-refund - Frontend: detail modal with installment list, refund request button, pending refunds tab with approve actions
This commit is contained in:
@@ -11,7 +11,7 @@ import {
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { DepositsService } from './deposits.service';
|
||||
import { CreateDepositDto, RefundDepositDto } from './dto/deposit.dto';
|
||||
import { CreateDepositDto, RefundDepositDto, CreateDepositWithInstallmentsDto } 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';
|
||||
@@ -34,15 +34,27 @@ export class DepositsController {
|
||||
});
|
||||
}
|
||||
|
||||
@Get('pending-refunds')
|
||||
@RequirePermission('deposit:edit')
|
||||
findPendingRefunds() {
|
||||
return this.service.findPendingRefunds();
|
||||
}
|
||||
|
||||
@Get('stats')
|
||||
@RequirePermission('deposit:view')
|
||||
getStats() {
|
||||
return this.service.getStats();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('deposit:view')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.service.findOne(+id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission('deposit:create')
|
||||
async create(@Body() dto: CreateDepositDto, @Request() req: any) {
|
||||
async create(@Body() dto: CreateDepositDto | CreateDepositWithInstallmentsDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.create(dto, req.user?.id);
|
||||
await this.logService.log({
|
||||
@@ -59,6 +71,30 @@ export class DepositsController {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post(':id/installments')
|
||||
@RequirePermission('deposit:edit')
|
||||
async addInstallment(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { amount: number; dueDate: string },
|
||||
) {
|
||||
return this.service.addInstallment(+id, body.amount, body.dueDate);
|
||||
}
|
||||
|
||||
@Put('installments/:installmentId')
|
||||
@RequirePermission('deposit:edit')
|
||||
async updateInstallment(
|
||||
@Param('installmentId') installmentId: string,
|
||||
@Body() body: { paidDate?: string; status?: string },
|
||||
) {
|
||||
return this.service.updateInstallment(+installmentId, body);
|
||||
}
|
||||
|
||||
@Delete('installments/:installmentId')
|
||||
@RequirePermission('deposit:delete')
|
||||
async deleteInstallment(@Param('installmentId') installmentId: string) {
|
||||
return this.service.deleteInstallment(+installmentId);
|
||||
}
|
||||
|
||||
@Put(':id/refund')
|
||||
@RequirePermission('deposit:edit')
|
||||
async refund(@Param('id') id: string, @Body() dto: RefundDepositDto, @Request() req: any) {
|
||||
@@ -78,6 +114,44 @@ export class DepositsController {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post(':id/request-refund')
|
||||
@RequirePermission('deposit:edit')
|
||||
async requestRefund(@Param('id') id: string, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.requestRefund(+id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '押金管理',
|
||||
action: '申请退款',
|
||||
targetId: +id,
|
||||
targetType: 'deposit',
|
||||
detail: '提交退款申请',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Put(':id/approve-refund')
|
||||
@RequirePermission('deposit:approve')
|
||||
async approveRefund(@Param('id') id: string, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.approveRefund(+id, req.user?.id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '押金管理',
|
||||
action: '审批退款',
|
||||
targetId: +id,
|
||||
targetType: 'deposit',
|
||||
detail: `审批通过 → ${result.refundStatus}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('deposit:delete')
|
||||
async remove(@Param('id') id: string, @Request() req: any) {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Deposit } from '../entities/deposit.entity';
|
||||
import { DepositInstallment } from '../entities/deposit-installment.entity';
|
||||
import { DepositsService } from './deposits.service';
|
||||
import { DepositsController } from './deposits.controller';
|
||||
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Deposit]), OperationLogsModule],
|
||||
imports: [TypeOrmModule.forFeature([Deposit, DepositInstallment]), OperationLogsModule],
|
||||
controllers: [DepositsController],
|
||||
providers: [DepositsService],
|
||||
exports: [DepositsService],
|
||||
|
||||
@@ -2,33 +2,84 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/comm
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Deposit } from '../entities/deposit.entity';
|
||||
import { CreateDepositDto, RefundDepositDto } from './dto/deposit.dto';
|
||||
import { DepositInstallment } from '../entities/deposit-installment.entity';
|
||||
import { CreateDepositDto, RefundDepositDto, CreateDepositWithInstallmentsDto } from './dto/deposit.dto';
|
||||
|
||||
@Injectable()
|
||||
export class DepositsService {
|
||||
constructor(@InjectRepository(Deposit) private repo: Repository<Deposit>) {}
|
||||
constructor(
|
||||
@InjectRepository(Deposit) private repo: Repository<Deposit>,
|
||||
@InjectRepository(DepositInstallment)
|
||||
private installmentRepo: Repository<DepositInstallment>,
|
||||
) {}
|
||||
|
||||
async findAll(query?: { studentId?: number; status?: string }) {
|
||||
const qb = this.repo
|
||||
.createQueryBuilder('d')
|
||||
.leftJoinAndSelect('d.student', 'student')
|
||||
.leftJoinAndSelect('d.installments', 'installments')
|
||||
.orderBy('d.createdAt', 'DESC');
|
||||
if (query?.studentId) qb.andWhere('d.studentId = :studentId', { studentId: query.studentId });
|
||||
if (query?.status) qb.andWhere('d.status = :status', { status: query.status });
|
||||
return qb.getMany();
|
||||
}
|
||||
|
||||
async findOne(id: number) {
|
||||
const deposit = await this.repo.findOne({ where: { id }, relations: ['student', 'installments'] });
|
||||
if (!deposit) throw new NotFoundException('押金记录不存在');
|
||||
return deposit;
|
||||
}
|
||||
|
||||
async create(dto: CreateDepositDto, userId?: number) {
|
||||
return this.repo.save(
|
||||
this.repo.create({
|
||||
studentId: dto.studentId,
|
||||
amount: dto.amount,
|
||||
paidDate: dto.paidDate,
|
||||
notes: dto.notes,
|
||||
status: 'paid',
|
||||
recordedBy: userId,
|
||||
}),
|
||||
);
|
||||
const deposit = this.repo.create({
|
||||
studentId: dto.studentId,
|
||||
amount: dto.amount,
|
||||
paidDate: dto.paidDate,
|
||||
notes: dto.notes,
|
||||
status: 'paid',
|
||||
recordedBy: userId,
|
||||
});
|
||||
|
||||
if (dto instanceof CreateDepositWithInstallmentsDto && dto.installments?.length) {
|
||||
deposit.installments = dto.installments.map((i) =>
|
||||
this.installmentRepo.create({
|
||||
amount: i.amount,
|
||||
dueDate: i.dueDate,
|
||||
status: 'pending',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return this.repo.save(deposit);
|
||||
}
|
||||
|
||||
async addInstallment(depositId: number, amount: number, dueDate: string) {
|
||||
const deposit = await this.repo.findOne({ where: { id: depositId } });
|
||||
if (!deposit) throw new NotFoundException('押金记录不存在');
|
||||
|
||||
const installment = this.installmentRepo.create({
|
||||
depositId,
|
||||
amount,
|
||||
dueDate,
|
||||
status: 'pending',
|
||||
});
|
||||
return this.installmentRepo.save(installment);
|
||||
}
|
||||
|
||||
async updateInstallment(id: number, data: { paidDate?: string; status?: string }) {
|
||||
const installment = await this.installmentRepo.findOne({ where: { id } });
|
||||
if (!installment) throw new NotFoundException('分期记录不存在');
|
||||
|
||||
if (data.paidDate !== undefined) installment.paidDate = data.paidDate;
|
||||
if (data.status !== undefined) installment.status = data.status;
|
||||
return this.installmentRepo.save(installment);
|
||||
}
|
||||
|
||||
async deleteInstallment(id: number) {
|
||||
const installment = await this.installmentRepo.findOne({ where: { id } });
|
||||
if (!installment) throw new NotFoundException('分期记录不存在');
|
||||
await this.installmentRepo.delete(id);
|
||||
return { message: '删除成功' };
|
||||
}
|
||||
|
||||
async refund(id: number, dto: RefundDepositDto, userId?: number) {
|
||||
@@ -48,9 +99,69 @@ export class DepositsService {
|
||||
deduction > 0 ? (refundAmount > 0 ? 'partial_refund' : 'deducted') : 'refunded';
|
||||
if (dto.notes) deposit.notes = dto.notes;
|
||||
|
||||
// Clear refund approval flow if direct refund
|
||||
deposit.refundStatus = null;
|
||||
deposit.refundRequestedAt = null;
|
||||
deposit.refundApprovedBy = null;
|
||||
deposit.refundApprovedAt = null;
|
||||
|
||||
return this.repo.save(deposit);
|
||||
}
|
||||
|
||||
// ---- Refund approval flow ----
|
||||
|
||||
async requestRefund(id: number) {
|
||||
const deposit = await this.repo.findOne({ where: { id } });
|
||||
if (!deposit) throw new NotFoundException('押金记录不存在');
|
||||
if (deposit.status !== 'paid') throw new BadRequestException('该押金已处理');
|
||||
if (deposit.refundStatus) throw new BadRequestException('已提交退款申请,请等待审批');
|
||||
|
||||
deposit.refundStatus = 'pending';
|
||||
deposit.refundRequestedAt = new Date();
|
||||
return this.repo.save(deposit);
|
||||
}
|
||||
|
||||
async approveRefund(id: number, userId: number) {
|
||||
const deposit = await this.repo.findOne({ where: { id } });
|
||||
if (!deposit) throw new NotFoundException('押金记录不存在');
|
||||
|
||||
if (!deposit.refundStatus || deposit.refundStatus === 'refunded') {
|
||||
throw new BadRequestException('未找到待审批的退款申请');
|
||||
}
|
||||
|
||||
const transitions: Record<string, string> = {
|
||||
pending: 'head_teacher_approved',
|
||||
head_teacher_approved: 'finance_approved',
|
||||
finance_approved: 'refunded',
|
||||
};
|
||||
|
||||
const nextStatus = transitions[deposit.refundStatus];
|
||||
if (!nextStatus) throw new BadRequestException(`无效的退款状态: ${deposit.refundStatus}`);
|
||||
|
||||
deposit.refundStatus = nextStatus;
|
||||
deposit.refundApprovedBy = userId;
|
||||
deposit.refundApprovedAt = new Date();
|
||||
|
||||
if (nextStatus === 'refunded') {
|
||||
deposit.status = 'refunded';
|
||||
deposit.refundDate = new Date().toISOString().slice(0, 10);
|
||||
deposit.refundAmount = Number(deposit.amount) - Number(deposit.deductionAmount || 0);
|
||||
}
|
||||
|
||||
return this.repo.save(deposit);
|
||||
}
|
||||
|
||||
async findPendingRefunds() {
|
||||
return this.repo.find({
|
||||
where: [
|
||||
{ refundStatus: 'pending' },
|
||||
{ refundStatus: 'head_teacher_approved' },
|
||||
],
|
||||
relations: ['student', 'installments'],
|
||||
order: { refundRequestedAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: number) {
|
||||
const deposit = await this.repo.findOne({ where: { id } });
|
||||
if (!deposit) throw new NotFoundException('押金记录不存在');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IsInt, IsNumber, IsString, IsOptional } from 'class-validator';
|
||||
import { IsInt, IsNumber, IsString, IsOptional, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class CreateDepositDto {
|
||||
@IsInt()
|
||||
@@ -15,6 +16,25 @@ export class CreateDepositDto {
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class CreateInstallmentDto {
|
||||
@IsNumber()
|
||||
amount: number;
|
||||
|
||||
@IsString()
|
||||
dueDate: string;
|
||||
}
|
||||
|
||||
export class UpdateInstallmentDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
paidDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
}
|
||||
|
||||
|
||||
export class RefundDepositDto {
|
||||
@IsString()
|
||||
refundDate: string;
|
||||
@@ -31,3 +51,15 @@ export class RefundDepositDto {
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
export class CreateDepositWithInstallmentsDto extends CreateDepositDto {
|
||||
@IsOptional()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateInstallmentDto)
|
||||
installments?: CreateInstallmentDto[];
|
||||
}
|
||||
|
||||
export class ApproveRefundDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
37
apps/server/src/entities/deposit-installment.entity.ts
Normal file
37
apps/server/src/entities/deposit-installment.entity.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Deposit } from './deposit.entity';
|
||||
|
||||
@Entity('deposit_installments')
|
||||
export class DepositInstallment {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ name: 'deposit_id' })
|
||||
depositId: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 10, scale: 2 })
|
||||
amount: number;
|
||||
|
||||
@Column({ name: 'due_date', type: 'date' })
|
||||
dueDate: string;
|
||||
|
||||
@Column({ name: 'paid_date', type: 'date', nullable: true })
|
||||
paidDate: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'pending' })
|
||||
status: string; // pending | paid
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@ManyToOne(() => Deposit, (d) => d.installments, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'deposit_id' })
|
||||
deposit: Deposit;
|
||||
}
|
||||
@@ -4,9 +4,11 @@ import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Student } from './student.entity';
|
||||
import { DepositInstallment } from './deposit-installment.entity';
|
||||
|
||||
@Entity('deposits')
|
||||
export class Deposit {
|
||||
@@ -44,10 +46,26 @@ export class Deposit {
|
||||
@Column({ name: 'recorded_by', nullable: true })
|
||||
recordedBy: number;
|
||||
|
||||
@Column({ name: 'refund_status', length: 30, nullable: true })
|
||||
refundStatus: string | null; // pending | head_teacher_approved | finance_approved | refunded
|
||||
|
||||
@Column({ name: 'refund_requested_at', nullable: true })
|
||||
refundRequestedAt: Date | null;
|
||||
|
||||
@Column({ name: 'refund_approved_by', type: 'integer', nullable: true })
|
||||
refundApprovedBy: number | null;
|
||||
|
||||
@Column({ name: 'refund_approved_at', nullable: true })
|
||||
refundApprovedAt: Date | null;
|
||||
|
||||
@OneToMany(() => DepositInstallment, (i) => i.deposit, { cascade: true, eager: true })
|
||||
installments: DepositInstallment[];
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@ManyToOne(() => Student, { eager: true })
|
||||
@JoinColumn({ name: 'student_id' })
|
||||
student: Student;
|
||||
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export { BillItem } from './bill-item.entity';
|
||||
export { User } from './user.entity';
|
||||
export { OperationLog } from './operation-log.entity';
|
||||
export { Deposit } from './deposit.entity';
|
||||
export { DepositInstallment } from './deposit-installment.entity';
|
||||
export { Classroom } from './classroom.entity';
|
||||
export { Tenant } from './tenant.entity';
|
||||
export { ClassroomRental } from './classroom-rental.entity';
|
||||
|
||||
Reference in New Issue
Block a user