Compare commits

...

3 Commits

5 changed files with 399 additions and 19 deletions

View File

@ -37,6 +37,8 @@ export const paymentMethodOptions = [
{ label: '信用卡', value: 'CARD' }
];
import type { PaymentPlanVO, PaymentPlanForm } from '@/api/edu/payment-plan/types';
export interface ContractPackageVO {
id: number | string;
contractId: number | string;
@ -107,7 +109,16 @@ export interface ContractVO extends BaseEntity {
installmentThird: number;
actualTotalReceived: number;
settlementDate: string;
/** 签约负责人(姓名, 旧字段保留兼容) */
signPerson: string;
/** 签约负责人ID(edu_staff.id) */
signUserId?: number | string;
/** 课程顾问姓名(查询翻译填充) */
salesName?: string;
/** 管家姓名(查询翻译填充) */
housekeeperName?: string;
/** 签约负责人姓名(查询翻译填充) */
signUserName?: string;
contractRecovered: string;
remittanceName: string;
needInvoice: string;
@ -141,6 +152,8 @@ export interface ContractVO extends BaseEntity {
logs: any[];
/** 里程碑列表(升学服务合同) */
milestones?: any[];
/** 缴费计划列表(分期合同) */
paymentPlans?: PaymentPlanVO[];
}
export interface ContractForm {
@ -161,9 +174,24 @@ export interface ContractForm {
isInstallment: string;
installmentSecond: number;
installmentThird: number;
/** 分期数 2 或 3 */
installmentCount?: number;
/** 缴费计划明细(分期时携带) */
paymentPlans?: PaymentPlanForm[];
/** 应收总额(前端汇总展示, 后端汇总覆盖) */
totalAmount?: number;
actualTotalReceived: number;
settlementDate: string;
/** 签约负责人(姓名, 旧字段保留兼容) */
signPerson: string;
/** 签约负责人ID(edu_staff.id) */
signUserId?: number | string;
/** 课程顾问姓名(查询翻译填充) */
salesName?: string;
/** 管家姓名(查询翻译填充) */
housekeeperName?: string;
/** 签约负责人姓名(查询翻译填充) */
signUserName?: string;
contractRecovered: string;
remittanceName: string;
needInvoice: string;

View File

@ -0,0 +1,18 @@
import request from '@/utils/request';
import { AxiosPromise } from 'axios';
import { PaymentPlanVO, PaymentReceiveForm, EduPaymentPlanBoPayload } from './types';
// 查询某合同的缴费计划列表
export function listPaymentPlan(contractId: number | string): AxiosPromise<PaymentPlanVO[]> {
return request({ url: '/edu/contract/payment-plan/list/' + contractId, method: 'get' });
}
// 修改单期计划(仅待收态)
export function updatePaymentPlan(data: EduPaymentPlanBoPayload) {
return request({ url: '/edu/contract/payment-plan', method: 'put', data });
}
// 收款确认(联动账本)
export function receivePayment(data: PaymentReceiveForm) {
return request({ url: '/edu/contract/payment-plan/receive', method: 'put', data });
}

View File

@ -0,0 +1,42 @@
export const paymentPlanStatusOptions = [
{ label: '待收', value: '0', elTagType: 'warning' },
{ label: '已收', value: '1', elTagType: 'success' },
{ label: '已作废', value: '4', elTagType: 'info' }
];
export interface PaymentPlanVO {
id: number | string;
contractId: number | string;
installmentNo: number;
planAmount: number;
planDate: string;
paidAmount: number;
paidDate: string;
paymentMethod: string;
status: string;
remark: string;
}
/** 表单用: 新增/修改合同时携带的计划明细 */
export interface PaymentPlanForm {
installmentNo: number;
planAmount: number;
planDate: string;
}
/** 修改单期计划载荷(匹配后端 EduPaymentPlanBo) */
export interface EduPaymentPlanBoPayload {
id: number | string;
planAmount?: number;
planDate?: string;
remark?: string;
}
/** 收款确认表单 */
export interface PaymentReceiveForm {
id: number | string;
paidAmount: number;
paymentMethod: string;
paidDate: string;
remark?: string;
}

View File

@ -0,0 +1,86 @@
<template>
<el-select
:model-value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:clearable="clearable"
filterable
style="width: 100%"
@update:model-value="onChange"
>
<el-option
v-for="item in options"
:key="item.id"
:label="item.name + (item.staffNo ? ' (' + item.staffNo + ')' : '')"
:value="item.id"
/>
</el-select>
</template>
<script setup lang="ts" name="StaffSelect">
import { staffOptionselect } from '@/api/edu/staff';
import { StaffVO } from '@/api/edu/staff/types';
interface PropType {
/** 选中的员工ID(staff.id) */
modelValue?: number | string;
/** 角色筛选 SALES/HOUSEKEEPER/TUTOR/... 不传则全部 */
role?: string;
placeholder?: string;
disabled?: boolean;
clearable?: boolean;
}
const prop = withDefaults(defineProps<PropType>(), {
modelValue: undefined,
role: undefined,
placeholder: '请选择',
disabled: false,
clearable: true
});
const emit = defineEmits(['update:modelValue', 'change']);
const options = ref<StaffVO[]>([]);
/** 拉取候选员工(按 role 过滤) */
const loadOptions = async () => {
const res = await staffOptionselect(prop.role);
options.value = res.data || [];
};
const onChange = (val: number | string) => {
emit('update:modelValue', val);
const staff = options.value.find((o) => o.id === val);
emit('change', val, staff);
};
/**
* 传入 staff id 时若当前候选不含, 单条补查并补入候选, 保证回显正确
*/
const ensureEcho = async () => {
if (prop.modelValue === undefined || prop.modelValue === null || prop.modelValue === '') return;
const exists = options.value.some((o) => o.id === prop.modelValue);
if (!exists) {
// , id role
const res = await staffOptionselect(undefined);
const all = res.data || [];
const found = all.find((o) => o.id === prop.modelValue);
if (found && !options.value.some((o) => o.id === found.id)) {
options.value.push(found);
}
}
};
watch(
() => prop.role,
() => {
loadOptions();
}
);
onMounted(async () => {
await loadOptions();
await ensureEcho();
});
</script>

View File

@ -139,7 +139,9 @@
<el-descriptions-item label="服务期限">{{ detailData.servicePeriodText || '-' }}</el-descriptions-item>
<el-descriptions-item label="服务合同">{{ detailData.serviceContractName || '-' }}</el-descriptions-item>
<el-descriptions-item label="激活时间">{{ proxy.parseTime(detailData.activatedAt) || '-' }}</el-descriptions-item>
<el-descriptions-item label="签约人">{{ detailData.signPerson || '-' }}</el-descriptions-item>
<el-descriptions-item label="课程顾问">{{ detailData.salesName || '-' }}</el-descriptions-item>
<el-descriptions-item label="管家">{{ detailData.housekeeperName || '-' }}</el-descriptions-item>
<el-descriptions-item label="签约人">{{ detailData.signUserName || detailData.signPerson || '-' }}</el-descriptions-item>
<el-descriptions-item label="签约地点">{{ detailData.signLocation || '-' }}</el-descriptions-item>
<el-descriptions-item label="合同回收">
<el-tag :type="detailData.contractRecovered === 'Y' ? 'success' : 'info'" size="small">{{ detailData.contractRecovered === 'Y' ? '已回收' : '未回收' }}</el-tag>
@ -153,14 +155,46 @@
<el-descriptions-item label="结算日期">{{ proxy.parseTime(detailData.settlementDate, '{y}-{m}-{d}') || '-' }}</el-descriptions-item>
</el-descriptions>
<!-- 分期信息 -->
<template v-if="detailData.isInstallment === 'Y'">
<!-- 缴费计划进度 -->
<template v-if="detailData.isInstallment === 'Y' && detailData.paymentPlans && detailData.paymentPlans.length > 0">
<el-card shadow="never" class="mt-3 mb-3">
<div class="mb-2" style="font-weight: 600; color: #606266;">分期信息</div>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="二期金额">¥{{ detailData.installmentSecond || 0 }}</el-descriptions-item>
<el-descriptions-item label="三期金额">¥{{ detailData.installmentThird || 0 }}</el-descriptions-item>
</el-descriptions>
<div class="mb-2" style="font-weight: 600; color: #606266;">缴费计划进度</div>
<el-table border :data="detailData.paymentPlans" size="small">
<el-table-column label="期次" width="70" align="center">
<template #default="{ row }">{{ row.installmentNo }}</template>
</el-table-column>
<el-table-column label="应收金额" prop="planAmount" align="center" width="100" />
<el-table-column label="应付日期" align="center" width="110">
<template #default="{ row }">{{ proxy.parseTime(row.planDate, '{y}-{m}-{d}') }}</template>
</el-table-column>
<el-table-column label="实收金额" prop="paidAmount" align="center" width="100" />
<el-table-column label="收款日期" align="center" width="110">
<template #default="{ row }">{{ row.paidDate ? proxy.parseTime(row.paidDate, '{y}-{m}-{d}') : '-' }}</template>
</el-table-column>
<el-table-column label="状态" align="center" width="80">
<template #default="{ row }">
<el-tag :type="getPlanStatusTag(row.status)" size="small">{{ getPlanStatusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center">
<template #default="{ row }">
<el-button
v-if="row.status === '0'"
v-hasPermi="['edu:contract:activate']"
link type="success" @click="handleReceive(row)"
>收款确认</el-button>
</template>
</el-table-column>
</el-table>
<div class="mt-2 plan-summary">
<span>应收 <b>¥{{ planSummary.total }}</b></span>
<span class="ml-3">已收 <b class="money-success">¥{{ planSummary.received }}</b></span>
<span class="ml-3">待收 <b class="money-warn">¥{{ planSummary.pending }}</b></span>
<el-progress
:percentage="planSummary.total ? Math.round(planSummary.received / planSummary.total * 100) : 0"
:stroke-width="14" :text-inside="true" class="plan-progress"
/>
</div>
</el-card>
</template>
@ -328,23 +362,54 @@
<el-row>
<el-col :span="8">
<el-form-item label="是否分期" prop="isInstallment">
<el-select v-model="form.isInstallment" placeholder="请选择" style="width: 100%">
<el-select v-model="form.isInstallment" placeholder="请选择" style="width: 100%" @change="onInstallmentChange">
<el-option label="是" value="Y" />
<el-option label="否" value="N" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8" v-if="form.isInstallment === 'Y'">
<el-form-item label="二期金额">
<el-input-number v-model="form.installmentSecond" :min="0" :precision="2" controls-position="right" style="width: 100%" />
<el-form-item label="分期数" prop="installmentCount">
<el-select v-model="form.installmentCount" placeholder="请选择" style="width: 100%" @change="autoGeneratePlan">
<el-option label="2 期" :value="2" />
<el-option label="3 期" :value="3" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8" v-if="form.isInstallment === 'Y'">
<el-form-item label="三期金额">
<el-input-number v-model="form.installmentThird" :min="0" :precision="2" controls-position="right" style="width: 100%" />
<el-form-item label="应收总额">
<el-input :model-value="computedTotalAmount" disabled />
</el-form-item>
</el-col>
</el-row>
<!-- 缴费计划明细(分期时显示) -->
<template v-if="form.isInstallment === 'Y'">
<el-divider content-position="left">
缴费计划
<el-button class="ml-2" type="primary" plain icon="MagicStick" size="small" @click="autoGeneratePlan">自动生成</el-button>
</el-divider>
<el-table border :data="form.paymentPlans" size="small">
<el-table-column label="期次" width="80" align="center">
<template #default="{ row }">{{ row.installmentNo }}</template>
</el-table-column>
<el-table-column label="应收金额" width="160" align="center">
<template #default="{ row }">
<el-input-number v-model="row.planAmount" :min="0" :precision="2" size="small" controls-position="right" />
</template>
</el-table-column>
<el-table-column label="应付日期" width="180" align="center">
<template #default="{ row }">
<el-date-picker v-model="row.planDate" type="date" value-format="YYYY-MM-DD" size="small" style="width: 100%" />
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template #default="{ $index }">
<el-button link type="danger" icon="Delete" @click="form.paymentPlans?.splice($index, 1)" />
</template>
</el-table-column>
</el-table>
</template>
<el-row>
<el-col :span="8">
<el-form-item label="实收总额" prop="actualTotalReceived">
@ -366,18 +431,18 @@
<el-divider content-position="left">签约信息</el-divider>
<el-row>
<el-col :span="8">
<el-form-item label="顾问ID" prop="salesId">
<el-input v-model="form.salesId" placeholder="课程顾问ID" />
<el-form-item label="课程顾问" prop="salesId">
<staff-select v-model="form.salesId" role="SALES" placeholder="课程顾问" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="管家ID" prop="housekeeperId">
<el-input v-model="form.housekeeperId" placeholder="管家ID" />
<el-form-item label="管家" prop="housekeeperId">
<staff-select v-model="form.housekeeperId" role="HOUSEKEEPER" placeholder="管家" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="签约人" prop="signPerson">
<el-input v-model="form.signPerson" placeholder="签约姓名" />
<el-form-item label="签约人" prop="signUserId">
<staff-select v-model="form.signUserId" placeholder="签约负责人" />
</el-form-item>
</el-col>
</el-row>
@ -610,6 +675,36 @@
<el-button @click="transferDialog.visible = false"> </el-button>
</template>
</el-dialog>
<!-- 收款确认弹窗 -->
<el-dialog v-model="receiveDialog.visible" title="缴费计划收款确认" width="420px" append-to-body>
<el-form label-width="100px">
<el-form-item label="期次">
<span> {{ receiveRow.installmentNo }} </span>
</el-form-item>
<el-form-item label="应收金额">
<span>¥{{ receiveRow.planAmount }}</span>
</el-form-item>
<el-form-item label="实收金额">
<el-input-number v-model="receiveForm.paidAmount" :min="0" :precision="2" controls-position="right" />
</el-form-item>
<el-form-item label="支付方式">
<el-select v-model="receiveForm.paymentMethod" placeholder="请选择" style="width: 100%">
<el-option v-for="opt in paymentMethodOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item label="收款日期">
<el-date-picker v-model="receiveForm.paidDate" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="receiveForm.remark" type="textarea" placeholder="备注" />
</el-form-item>
</el-form>
<template #footer>
<el-button type="success" @click="submitReceive">确认收款</el-button>
<el-button @click="receiveDialog.visible = false"> </el-button>
</template>
</el-dialog>
</div>
</template>
@ -640,6 +735,8 @@ import {
} from '@/api/edu/contract/types';
import { deliverMilestone } from '@/api/edu/milestone';
import { milestoneStatusOptions } from '@/api/edu/milestone/types';
import { receivePayment } from '@/api/edu/payment-plan';
import { PaymentReceiveForm, PaymentPlanForm, paymentPlanStatusOptions } from '@/api/edu/payment-plan/types';
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
@ -654,12 +751,15 @@ const dialog = reactive<DialogOption>({ visible: false, title: '' });
const activateDialog = reactive<DialogOption>({ visible: false, title: '' });
const refundDialog = reactive<DialogOption>({ visible: false, title: '' });
const transferDialog = reactive<DialogOption>({ visible: false, title: '' });
const receiveDialog = reactive<DialogOption>({ visible: false, title: '' });
const detailVisible = ref(false);
const detailData = ref<ContractVO>({} as ContractVO);
const activateRow = ref<ContractVO>({} as ContractVO);
const refundRow = ref<ContractVO>({} as ContractVO);
const transferRow = ref<ContractVO>({} as ContractVO);
const receiveRow = ref<any>({});
const receiveForm = reactive<PaymentReceiveForm>({ id: '', paidAmount: 0, paymentMethod: 'WECHAT', paidDate: '', remark: '' });
const initFormData: ContractForm = {
id: undefined,
@ -679,9 +779,12 @@ const initFormData: ContractForm = {
isInstallment: 'N',
installmentSecond: 0,
installmentThird: 0,
installmentCount: 2,
paymentPlans: [],
actualTotalReceived: 0,
settlementDate: '',
signPerson: '',
signUserId: undefined,
contractRecovered: 'N',
remittanceName: '',
needInvoice: 'N',
@ -743,6 +846,8 @@ const getStatusTag = (val: string) => contractStatusOptions.find((o) => o.value
const getPayMethodLabel = (val: string) => paymentMethodOptions.find((o) => o.value === val)?.label || val;
const getMilestoneLabel = (val: string) => milestoneStatusOptions.find((o) => o.value === val)?.label || val;
const getMilestoneTag = (val: string) => milestoneStatusOptions.find((o) => o.value === val)?.elTagType || 'info';
const getPlanStatusLabel = (val: string) => paymentPlanStatusOptions.find((o) => o.value === val)?.label || val;
const getPlanStatusTag = (val: string) => paymentPlanStatusOptions.find((o) => o.value === val)?.elTagType || 'info';
/** 课时进度颜色(>80%红色预警, >50%橙色, 否则绿色) */
const hoursProgressColor = computed(() => {
@ -751,6 +856,84 @@ const hoursProgressColor = computed(() => {
return pct >= 80 ? '#f56c6c' : pct >= 50 ? '#e6a23c' : '#67c23a';
});
/** 应收总额(从课包明细汇总) */
const computedTotalAmount = computed(() => {
return (form.value.packages || []).reduce((sum, p) => sum + (Number(p.finalAmount) || Number(p.salePrice) || 0), 0);
});
/** 缴费计划汇总(详情卡片用) */
const planSummary = computed(() => {
const plans: any[] = detailData.value.paymentPlans || [];
const total = plans.reduce((s, p) => s + (Number(p.planAmount) || 0), 0);
const received = plans
.filter((p: any) => p.status === '1')
.reduce((s, p) => s + (Number(p.paidAmount) || 0), 0);
const pending = plans
.filter((p: any) => p.status === '0')
.reduce((s, p) => s + (Number(p.planAmount) || 0), 0);
return { total, received, pending };
});
/** 分期开关变化 */
const onInstallmentChange = (val: string) => {
if (val === 'Y') {
if (!form.value.installmentCount) form.value.installmentCount = 2;
autoGeneratePlan();
} else {
form.value.paymentPlans = [];
}
};
/** 按规则自动生成缴费计划: 首期吸收尾差, 日期按 30 天间隔 */
const autoGeneratePlan = () => {
const count = form.value.installmentCount;
if (!count || (count !== 2 && count !== 3)) {
proxy?.$modal.msgWarning('请选择分期数(2 或 3)');
return;
}
const total = Number(computedTotalAmount.value) || 0;
if (total <= 0) {
proxy?.$modal.msgWarning('应收总额大于 0 才能生成计划');
return;
}
// base = total/count, 2~N base, 1
const base = Math.round((total / count) * 100) / 100;
const first = Math.round((total - base * (count - 1)) * 100) / 100;
const signDate = form.value.signDate || proxy.parseTime(new Date(), '{y}-{m}-{d}');
const signMs = new Date(signDate).getTime();
const plans: PaymentPlanForm[] = [];
for (let i = 1; i <= count; i++) {
plans.push({
installmentNo: i,
planAmount: i === 1 ? first : base,
planDate: proxy.parseTime(new Date(signMs + 30 * (i - 1) * 86400000), '{y}-{m}-{d}')
});
}
form.value.paymentPlans = plans;
};
/** 缴费计划收款确认 */
const handleReceive = (row: any) => {
receiveRow.value = row;
receiveForm.id = row.id;
receiveForm.paidAmount = row.planAmount;
receiveForm.paymentMethod = detailData.value.paymentMethod || 'WECHAT';
receiveForm.paidDate = proxy.parseTime(new Date(), '{y}-{m}-{d}');
receiveForm.remark = '';
receiveDialog.visible = true;
};
const submitReceive = async () => {
await proxy?.$modal.confirm(`确认第 ${receiveRow.value.installmentNo} 期收款 ${receiveForm.paidAmount} 元?`);
await receivePayment({ ...receiveForm });
proxy?.$modal.msgSuccess('收款成功');
receiveDialog.visible = false;
//
const res = await getContract(detailData.value.id);
detailData.value = res.data;
await getList();
};
const getList = async () => {
loading.value = true;
const res = await listContract(queryParams.value);
@ -790,6 +973,13 @@ const handleUpdate = async (row: ContractVO) => {
const res = await getContract(row.id);
Object.assign(form.value, res.data);
form.value.packages = res.data.packages || [];
// (: //)
form.value.paymentPlans = (res.data.paymentPlans || []).map((p: any) => ({
installmentNo: p.installmentNo,
planAmount: p.planAmount,
planDate: p.planDate
}));
form.value.installmentCount = res.data.paymentPlans?.length || 2;
dialog.visible = true;
dialog.title = '修改合同';
};
@ -801,6 +991,20 @@ const submitForm = () => {
proxy?.$modal.msgWarning('请至少添加一个课包明细');
return;
}
// : +
if (form.value.isInstallment === 'Y') {
if (!form.value.installmentCount || form.value.paymentPlans?.length !== form.value.installmentCount) {
proxy?.$modal.msgWarning('缴费计划期数与分期数不一致, 请点"自动生成"');
return;
}
const sum = (form.value.paymentPlans || []).reduce((s, p) => s + (Number(p.planAmount) || 0), 0);
if (Math.abs(sum - computedTotalAmount.value) > 0.01) {
proxy?.$modal.msgWarning(`计划金额之和 ${sum.toFixed(2)} 与应收总额 ${computedTotalAmount.value.toFixed(2)} 不一致`);
return;
}
} else {
form.value.paymentPlans = [];
}
form.value.id ? await updateContract(form.value) : await addContract(form.value);
proxy?.$modal.msgSuccess('操作成功');
dialog.visible = false;
@ -926,5 +1130,7 @@ onMounted(() => {
.hours-progress { display: flex; align-items: center; gap: 12px; }
.hours-label { white-space: nowrap; color: #606266; font-weight: 600; }
.hours-detail { white-space: nowrap; font-size: 13px; color: #909399; }
.plan-summary { display: flex; align-items: center; gap: 4px; font-size: 13px; color: #606266; }
.plan-progress { flex: 1; margin-left: 12px; max-width: 280px; }
:deep(.el-drawer__body) { padding-top: 12px; }
</style>