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:
2026-07-05 20:46:05 +08:00
parent 5df70a8af0
commit ab4adf1174
8 changed files with 645 additions and 82 deletions

View File

@@ -12,8 +12,11 @@ import {
message, message,
Tag, Tag,
Popconfirm, Popconfirm,
Tabs,
List,
Card,
} from 'antd'; } from 'antd';
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons'; import { PlusOutlined, DeleteOutlined, DollarOutlined, CheckOutlined } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import api from '../../api'; import api from '../../api';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
@@ -25,16 +28,34 @@ const statusMap: Record<string, { text: string; color: string }> = {
deducted: { text: '已全扣', color: 'red' }, deducted: { text: '已全扣', color: 'red' },
}; };
const refundStatusMap: Record<string, { text: string; color: string }> = {
pending: { text: '待班主任审批', color: 'orange' },
head_teacher_approved: { text: '待财务审批', color: 'blue' },
finance_approved: { text: '已退款', color: 'green' },
refunded: { text: '已退款', color: 'green' },
};
const installmentStatusMap: Record<string, { text: string; color: string }> = {
pending: { text: '待缴', color: 'orange' },
paid: { text: '已缴', color: 'green' },
};
const DepositsPage: React.FC = () => { const DepositsPage: React.FC = () => {
const [data, setData] = useState<any[]>([]); const [data, setData] = useState<any[]>([]);
const [students, setStudents] = useState<any[]>([]); const [students, setStudents] = useState<any[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [createModal, setCreateModal] = useState(false); const [createModal, setCreateModal] = useState(false);
const [refundModal, setRefundModal] = useState<any>(null); const [refundModal, setRefundModal] = useState<any>(null);
const [detailModal, setDetailModal] = useState<any>(null);
const [installmentModal, setInstallmentModal] = useState<number | null>(null);
const [pendingRefunds, setPendingRefunds] = useState<any[]>([]);
const [pendingLoading, setPendingLoading] = useState(false);
const [createForm] = Form.useForm(); const [createForm] = Form.useForm();
const [refundForm] = Form.useForm(); const [refundForm] = Form.useForm();
const [installmentForm] = Form.useForm();
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState('');
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined); const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
const [activeTab, setActiveTab] = useState<string>('all');
const fetchData = async () => { const fetchData = async () => {
setLoading(true); setLoading(true);
@@ -48,6 +69,17 @@ const DepositsPage: React.FC = () => {
setLoading(false); setLoading(false);
}; };
const fetchPendingRefunds = async () => {
setPendingLoading(true);
try {
const res = await api.get('/deposits/pending-refunds');
setPendingRefunds(res || []);
} catch (e) {
console.error(e);
}
setPendingLoading(false);
};
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
}, []); }, []);
@@ -99,6 +131,67 @@ const DepositsPage: React.FC = () => {
} }
}; };
const handleRequestRefund = async (record: any) => {
try {
await api.post(`/deposits/${record.id}/request-refund`);
message.success('退款申请已提交');
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const handleApproveRefund = async (record: any) => {
try {
await api.put(`/deposits/${record.id}/approve-refund`);
message.success('审批通过');
fetchPendingRefunds();
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const handleAddInstallment = async () => {
if (installmentModal == null) return;
const values = await installmentForm.validateFields();
try {
await api.post(`/deposits/${installmentModal}/installments`, {
amount: values.amount,
dueDate: values.dueDate.format('YYYY-MM-DD'),
});
message.success('分期已添加');
setInstallmentModal(null);
installmentForm.resetFields();
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const handlePayInstallment = async (installmentId: number) => {
try {
await api.put(`/deposits/installments/${installmentId}`, {
paidDate: dayjs().format('YYYY-MM-DD'),
status: 'paid',
});
message.success('分期已标记为已缴');
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const handleDeleteInstallment = async (installmentId: number) => {
try {
await api.delete(`/deposits/installments/${installmentId}`);
message.success('分期已删除');
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const columns = [ const columns = [
{ title: '学生', render: (_: any, r: any) => r.student?.name || '-' }, { title: '学生', render: (_: any, r: any) => r.student?.name || '-' },
{ title: '押金金额', dataIndex: 'amount', render: (v: number) => `¥${Number(v).toFixed(2)}` }, { title: '押金金额', dataIndex: 'amount', render: (v: number) => `¥${Number(v).toFixed(2)}` },
@@ -108,6 +201,12 @@ const DepositsPage: React.FC = () => {
dataIndex: 'status', dataIndex: 'status',
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>, render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>,
}, },
{
title: '退款审批',
dataIndex: 'refundStatus',
render: (s: string) =>
s ? <Tag color={refundStatusMap[s]?.color}>{refundStatusMap[s]?.text || s}</Tag> : '-',
},
{ {
title: '退还金额', title: '退还金额',
dataIndex: 'refundAmount', dataIndex: 'refundAmount',
@@ -123,21 +222,39 @@ const DepositsPage: React.FC = () => {
{ title: '备注', dataIndex: 'notes', render: (v: any) => v || '-' }, { title: '备注', dataIndex: 'notes', render: (v: any) => v || '-' },
{ {
title: '操作', title: '操作',
width: 160, width: 240,
render: (_: any, record: any) => ( render: (_: any, record: any) => (
<Space> <Space>
{record.status === 'paid' && ( <PermissionButton
<PermissionButton permission="deposit:view"
permission="deposit:edit" size="small"
size="small" onClick={() => {
type="primary" setDetailModal(record);
onClick={() => { }}
setRefundModal(record); >
refundForm.setFieldsValue({ refundDate: dayjs(), deductionAmount: 0 });
}} </PermissionButton>
> {record.status === 'paid' && !record.refundStatus && (
退 <>
</PermissionButton> <PermissionButton
permission="deposit:edit"
size="small"
type="primary"
onClick={() => {
setRefundModal(record);
refundForm.setFieldsValue({ refundDate: dayjs(), deductionAmount: 0 });
}}
>
退
</PermissionButton>
<PermissionButton
permission="deposit:edit"
size="small"
onClick={() => handleRequestRefund(record)}
>
退
</PermissionButton>
</>
)} )}
<PermissionButton permission="deposit:delete"> <PermissionButton permission="deposit:delete">
<Popconfirm <Popconfirm
@@ -160,63 +277,125 @@ const DepositsPage: React.FC = () => {
}, },
]; ];
const pendingColumns = [
{ title: '学生', render: (_: any, r: any) => r.student?.name || '-' },
{ title: '押金金额', dataIndex: 'amount', render: (v: number) => `¥${Number(v).toFixed(2)}` },
{ title: '缴纳日期', dataIndex: 'paidDate' },
{
title: '审批状态',
dataIndex: 'refundStatus',
render: (s: string) => <Tag color={refundStatusMap[s]?.color}>{refundStatusMap[s]?.text || s}</Tag>,
},
{
title: '申请时间',
dataIndex: 'refundRequestedAt',
render: (v: any) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-'),
},
{
title: '操作',
render: (_: any, record: any) => (
<PermissionButton
permission="deposit:approve"
size="small"
type="primary"
icon={<CheckOutlined />}
onClick={() => handleApproveRefund(record)}
>
</PermissionButton>
),
},
];
return ( return (
<div> <div>
<div <Tabs
style={{ activeKey={activeTab}
marginBottom: 16, onChange={(key) => {
display: 'flex', setActiveTab(key);
justifyContent: 'space-between', if (key === 'pending') fetchPendingRefunds();
flexWrap: 'wrap',
gap: 8,
}} }}
> items={[
<Space wrap> {
<Input.Search key: 'all',
placeholder="搜索学生姓名" label: '押金列表',
allowClear children: (
style={{ width: 180 }} <>
onSearch={(v) => setSearchText(v)} <div
onChange={(e) => { style={{
if (!e.target.value) setSearchText(''); marginBottom: 16,
}} display: 'flex',
/> justifyContent: 'space-between',
<Select flexWrap: 'wrap',
placeholder="状态筛选" gap: 8,
allowClear }}
style={{ width: 120 }} >
value={filterStatus} <Space wrap>
onChange={(v) => setFilterStatus(v)} <Input.Search
options={[ placeholder="搜索学生姓名"
{ value: 'paid', label: '已缴' }, allowClear
{ value: 'refunded', label: '已全退' }, style={{ width: 180 }}
{ value: 'partial_refund', label: '部分退还' }, onSearch={(v) => setSearchText(v)}
{ value: 'deducted', label: '已全扣' }, onChange={(e) => {
]} if (!e.target.value) setSearchText('');
/> }}
</Space> />
<PermissionButton <Select
permission="deposit:create" placeholder="状态筛选"
type="primary" allowClear
icon={<PlusOutlined />} style={{ width: 120 }}
onClick={() => { value={filterStatus}
createForm.resetFields(); onChange={(v) => setFilterStatus(v)}
createForm.setFieldsValue({ amount: 500, paidDate: dayjs() }); options={[
setCreateModal(true); { value: 'paid', label: '已缴' },
}} { value: 'refunded', label: '已全退' },
> { value: 'partial_refund', label: '部分退还' },
{ value: 'deducted', label: '已全扣' },
</PermissionButton> ]}
</div> />
<Table </Space>
columns={columns} <PermissionButton
dataSource={filteredData} permission="deposit:create"
rowKey="id" type="primary"
loading={loading} icon={<PlusOutlined />}
scroll={{ x: 1000 }} onClick={() => {
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }} createForm.resetFields();
createForm.setFieldsValue({ amount: 500, paidDate: dayjs() });
setCreateModal(true);
}}
>
</PermissionButton>
</div>
<Table
columns={columns}
dataSource={filteredData}
rowKey="id"
loading={loading}
scroll={{ x: 1300 }}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
/>
</>
),
},
{
key: 'pending',
label: '待审批退款',
children: (
<Table
columns={pendingColumns}
dataSource={pendingRefunds}
rowKey="id"
loading={pendingLoading}
scroll={{ x: 800 }}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
/>
),
},
]}
/> />
{/* Create Modal */}
<Modal <Modal
title="收取押金" title="收取押金"
open={createModal} open={createModal}
@@ -254,6 +433,7 @@ const DepositsPage: React.FC = () => {
</Form> </Form>
</Modal> </Modal>
{/* Refund Modal */}
<Modal <Modal
title={`退还押金 - ${refundModal?.student?.name}`} title={`退还押金 - ${refundModal?.student?.name}`}
open={!!refundModal} open={!!refundModal}
@@ -284,6 +464,115 @@ const DepositsPage: React.FC = () => {
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>
{/* Detail Modal */}
<Modal
title={`押金详情 - ${detailModal?.student?.name}`}
open={!!detailModal}
onCancel={() => setDetailModal(null)}
footer={null}
width={640}
>
{detailModal && (
<div>
<Card size="small" style={{ marginBottom: 16 }}>
<p><strong>:</strong> ¥{Number(detailModal.amount).toFixed(2)}</p>
<p><strong>:</strong> {detailModal.paidDate}</p>
<p>
<strong>:</strong>{' '}
<Tag color={statusMap[detailModal.status]?.color}>
{statusMap[detailModal.status]?.text || detailModal.status}
</Tag>
</p>
{detailModal.refundStatus && (
<p>
<strong>退:</strong>{' '}
<Tag color={refundStatusMap[detailModal.refundStatus]?.color}>
{refundStatusMap[detailModal.refundStatus]?.text || detailModal.refundStatus}
</Tag>
</p>
)}
{detailModal.notes && <p><strong>:</strong> {detailModal.notes}</p>}
</Card>
{/* Installments Section */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<h4 style={{ margin: 0 }}></h4>
<PermissionButton
permission="deposit:edit"
size="small"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setInstallmentModal(detailModal.id);
installmentForm.resetFields();
}}
>
</PermissionButton>
</div>
{detailModal.installments?.length > 0 ? (
<List
dataSource={detailModal.installments}
renderItem={(item: any) => (
<List.Item
actions={[
item.status === 'pending' && (
<PermissionButton
key="pay"
permission="deposit:edit"
size="small"
type="primary"
icon={<DollarOutlined />}
onClick={() => handlePayInstallment(item.id)}
>
</PermissionButton>
),
<PermissionButton key="del" permission="deposit:delete">
<Popconfirm
title="确定删除?"
onConfirm={() => handleDeleteInstallment(item.id)}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</PermissionButton>,
].filter(Boolean)}
>
<List.Item.Meta
title={`¥${Number(item.amount).toFixed(2)}`}
description={`到期: ${item.dueDate}${item.paidDate ? ` | 缴纳: ${item.paidDate}` : ''}`}
/>
<Tag color={installmentStatusMap[item.status]?.color}>
{installmentStatusMap[item.status]?.text || item.status}
</Tag>
</List.Item>
)}
/>
) : (
<p style={{ color: '#999' }}></p>
)}
</div>
)}
</Modal>
{/* Add Installment Modal */}
<Modal
title="添加分期"
open={installmentModal != null}
onOk={handleAddInstallment}
onCancel={() => setInstallmentModal(null)}
okText="确认"
>
<Form form={installmentForm} layout="vertical">
<Form.Item name="amount" label="分期金额(元)" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="dueDate" label="到期日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} placeholder="选择到期日期" format="YYYY-MM-DD" />
</Form.Item>
</Form>
</Modal>
</div> </div>
); );
}; };

View File

@@ -11,7 +11,7 @@ import {
Request, Request,
} from '@nestjs/common'; } from '@nestjs/common';
import { DepositsService } from './deposits.service'; 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 { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils'; 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') @Get('stats')
@RequirePermission('deposit:view') @RequirePermission('deposit:view')
getStats() { getStats() {
return this.service.getStats(); return this.service.getStats();
} }
@Get(':id')
@RequirePermission('deposit:view')
findOne(@Param('id') id: string) {
return this.service.findOne(+id);
}
@Post() @Post()
@RequirePermission('deposit:create') @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 { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.create(dto, req.user?.id); const result = await this.service.create(dto, req.user?.id);
await this.logService.log({ await this.logService.log({
@@ -59,6 +71,30 @@ export class DepositsController {
return result; 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') @Put(':id/refund')
@RequirePermission('deposit:edit') @RequirePermission('deposit:edit')
async refund(@Param('id') id: string, @Body() dto: RefundDepositDto, @Request() req: any) { async refund(@Param('id') id: string, @Body() dto: RefundDepositDto, @Request() req: any) {
@@ -78,6 +114,44 @@ export class DepositsController {
return result; 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') @Delete(':id')
@RequirePermission('deposit:delete') @RequirePermission('deposit:delete')
async remove(@Param('id') id: string, @Request() req: any) { async remove(@Param('id') id: string, @Request() req: any) {

View File

@@ -1,12 +1,13 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { Deposit } from '../entities/deposit.entity'; import { Deposit } from '../entities/deposit.entity';
import { DepositInstallment } from '../entities/deposit-installment.entity';
import { DepositsService } from './deposits.service'; import { DepositsService } from './deposits.service';
import { DepositsController } from './deposits.controller'; import { DepositsController } from './deposits.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module'; import { OperationLogsModule } from '../operation-logs/operation-logs.module';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Deposit]), OperationLogsModule], imports: [TypeOrmModule.forFeature([Deposit, DepositInstallment]), OperationLogsModule],
controllers: [DepositsController], controllers: [DepositsController],
providers: [DepositsService], providers: [DepositsService],
exports: [DepositsService], exports: [DepositsService],

View File

@@ -2,33 +2,84 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/comm
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { Deposit } from '../entities/deposit.entity'; 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() @Injectable()
export class DepositsService { 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 }) { async findAll(query?: { studentId?: number; status?: string }) {
const qb = this.repo const qb = this.repo
.createQueryBuilder('d') .createQueryBuilder('d')
.leftJoinAndSelect('d.student', 'student') .leftJoinAndSelect('d.student', 'student')
.leftJoinAndSelect('d.installments', 'installments')
.orderBy('d.createdAt', 'DESC'); .orderBy('d.createdAt', 'DESC');
if (query?.studentId) qb.andWhere('d.studentId = :studentId', { studentId: query.studentId }); if (query?.studentId) qb.andWhere('d.studentId = :studentId', { studentId: query.studentId });
if (query?.status) qb.andWhere('d.status = :status', { status: query.status }); if (query?.status) qb.andWhere('d.status = :status', { status: query.status });
return qb.getMany(); 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) { async create(dto: CreateDepositDto, userId?: number) {
return this.repo.save( const deposit = this.repo.create({
this.repo.create({ studentId: dto.studentId,
studentId: dto.studentId, amount: dto.amount,
amount: dto.amount, paidDate: dto.paidDate,
paidDate: dto.paidDate, notes: dto.notes,
notes: dto.notes, status: 'paid',
status: 'paid', recordedBy: userId,
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) { async refund(id: number, dto: RefundDepositDto, userId?: number) {
@@ -48,9 +99,69 @@ export class DepositsService {
deduction > 0 ? (refundAmount > 0 ? 'partial_refund' : 'deducted') : 'refunded'; deduction > 0 ? (refundAmount > 0 ? 'partial_refund' : 'deducted') : 'refunded';
if (dto.notes) deposit.notes = dto.notes; 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); 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) { async remove(id: number) {
const deposit = await this.repo.findOne({ where: { id } }); const deposit = await this.repo.findOne({ where: { id } });
if (!deposit) throw new NotFoundException('押金记录不存在'); if (!deposit) throw new NotFoundException('押金记录不存在');

View File

@@ -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 { export class CreateDepositDto {
@IsInt() @IsInt()
@@ -15,6 +16,25 @@ export class CreateDepositDto {
notes?: string; 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 { export class RefundDepositDto {
@IsString() @IsString()
refundDate: string; refundDate: string;
@@ -31,3 +51,15 @@ export class RefundDepositDto {
@IsString() @IsString()
notes?: string; notes?: string;
} }
export class CreateDepositWithInstallmentsDto extends CreateDepositDto {
@IsOptional()
@ValidateNested({ each: true })
@Type(() => CreateInstallmentDto)
installments?: CreateInstallmentDto[];
}
export class ApproveRefundDto {
@IsOptional()
@IsString()
notes?: string;
}

View 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;
}

View File

@@ -4,9 +4,11 @@ import {
Column, Column,
CreateDateColumn, CreateDateColumn,
ManyToOne, ManyToOne,
OneToMany,
JoinColumn, JoinColumn,
} from 'typeorm'; } from 'typeorm';
import { Student } from './student.entity'; import { Student } from './student.entity';
import { DepositInstallment } from './deposit-installment.entity';
@Entity('deposits') @Entity('deposits')
export class Deposit { export class Deposit {
@@ -44,10 +46,26 @@ export class Deposit {
@Column({ name: 'recorded_by', nullable: true }) @Column({ name: 'recorded_by', nullable: true })
recordedBy: number; 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' }) @CreateDateColumn({ name: 'created_at' })
createdAt: Date; createdAt: Date;
@ManyToOne(() => Student, { eager: true }) @ManyToOne(() => Student, { eager: true })
@JoinColumn({ name: 'student_id' }) @JoinColumn({ name: 'student_id' })
student: Student; student: Student;
} }

View File

@@ -8,6 +8,7 @@ export { BillItem } from './bill-item.entity';
export { User } from './user.entity'; export { User } from './user.entity';
export { OperationLog } from './operation-log.entity'; export { OperationLog } from './operation-log.entity';
export { Deposit } from './deposit.entity'; export { Deposit } from './deposit.entity';
export { DepositInstallment } from './deposit-installment.entity';
export { Classroom } from './classroom.entity'; export { Classroom } from './classroom.entity';
export { Tenant } from './tenant.entity'; export { Tenant } from './tenant.entity';
export { ClassroomRental } from './classroom-rental.entity'; export { ClassroomRental } from './classroom-rental.entity';