优化回款功能
This commit is contained in:
Vendored
+1
@@ -15,6 +15,7 @@ declare module 'vue' {
|
|||||||
CommonHeader: typeof import('./src/components/CommonHeader.vue')['default']
|
CommonHeader: typeof import('./src/components/CommonHeader.vue')['default']
|
||||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||||
ElAside: typeof import('element-plus/es')['ElAside']
|
ElAside: typeof import('element-plus/es')['ElAside']
|
||||||
|
ElAutocomplete: typeof import('element-plus/es')['ElAutocomplete']
|
||||||
ElAvatar: typeof import('element-plus/es')['ElAvatar']
|
ElAvatar: typeof import('element-plus/es')['ElAvatar']
|
||||||
ElBacktop: typeof import('element-plus/es')['ElBacktop']
|
ElBacktop: typeof import('element-plus/es')['ElBacktop']
|
||||||
ElBadge: typeof import('element-plus/es')['ElBadge']
|
ElBadge: typeof import('element-plus/es')['ElBadge']
|
||||||
|
|||||||
@@ -47,3 +47,22 @@ export function getPaybackProgress(params) {
|
|||||||
export function registerPaybackReceive(id, data) {
|
export function registerPaybackReceive(id, data) {
|
||||||
return request({ url: `/backend/crm/payback/${id}/receive`, method: "post", data });
|
return request({ url: `/backend/crm/payback/${id}/receive`, method: "post", data });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================ 回款记录(回款进度流水) ============================
|
||||||
|
* 以「回款计划」为先导条件:每次「新建回款」追加一条记录,构成回款进度列表。
|
||||||
|
* ============================================================================== */
|
||||||
|
|
||||||
|
/** 回款记录列表:按合同 / 计划查询一笔笔实际回款(含计划总额与已回款汇总) */
|
||||||
|
export function getPaybackRecordList(params) {
|
||||||
|
return request({ url: "/backend/crm/payback/record/list", method: "get", params });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新建回款:追加一条回款记录 */
|
||||||
|
export function createPaybackRecord(data) {
|
||||||
|
return request({ url: "/backend/crm/payback/record", method: "post", data });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除回款记录 */
|
||||||
|
export function deletePaybackRecord(id) {
|
||||||
|
return request({ url: `/backend/crm/payback/record/${id}`, method: "delete" });
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,29 +41,25 @@
|
|||||||
maxlength="80"
|
maxlength="80"
|
||||||
/>
|
/>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<el-select
|
<!-- 用 autocomplete:直接输入即生效(产品管理无此名称时作为新增),
|
||||||
|
也可从下拉建议中选择已有产品并带出规格/单位/单价/成本 -->
|
||||||
|
<el-autocomplete
|
||||||
v-model="row.name"
|
v-model="row.name"
|
||||||
filterable
|
|
||||||
allow-create
|
|
||||||
default-first-option
|
|
||||||
clearable
|
clearable
|
||||||
|
value-key="product_name"
|
||||||
:disabled="!row.category"
|
:disabled="!row.category"
|
||||||
|
:fetch-suggestions="(q, cb) => queryProducts(row, q, cb)"
|
||||||
:placeholder="row.category ? '搜索或输入产品名称' : '请先选择产品类别'"
|
:placeholder="row.category ? '搜索或输入产品名称' : '请先选择产品类别'"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
@focus="onProductFocus(row)"
|
@focus="onProductFocus(row)"
|
||||||
@visible-change="(v) => { if (v) onProductFocus(row) }"
|
@select="(item) => onProductPick(row, item.product_name)"
|
||||||
@change="(val) => onProductPick(row, val)"
|
@blur="() => onProductNameCommit(row)"
|
||||||
>
|
>
|
||||||
<el-option
|
<template #default="{ item }">
|
||||||
v-for="p in productOptions"
|
<span class="opt-name">{{ item.product_name }}</span>
|
||||||
:key="p.id"
|
<span class="opt-meta">{{ item.spec || "—" }} · ¥{{ item.price }}</span>
|
||||||
:label="p.product_name"
|
</template>
|
||||||
:value="p.product_name"
|
</el-autocomplete>
|
||||||
>
|
|
||||||
<span class="opt-name">{{ p.product_name }}</span>
|
|
||||||
<span class="opt-meta">{{ p.spec || "—" }} · ¥{{ p.price }}</span>
|
|
||||||
</el-option>
|
|
||||||
</el-select>
|
|
||||||
<!-- <div v-if="row.product_id" class="linked-tip">
|
<!-- <div v-if="row.product_id" class="linked-tip">
|
||||||
已关联产品管理 #{{ row.product_id }}(成本取自产品管理)
|
已关联产品管理 #{{ row.product_id }}(成本取自产品管理)
|
||||||
</div> -->
|
</div> -->
|
||||||
@@ -128,6 +124,11 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="税率(%)" width="110">
|
<el-table-column label="税率(%)" width="110">
|
||||||
|
<template #header>
|
||||||
|
<el-tooltip content="仅作开票记录:单价已为含税价,小计=数量×单价,不再叠加计税" placement="top">
|
||||||
|
<span>税率(%)</span>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-if="row.line_type === 'product'"
|
v-if="row.line_type === 'product'"
|
||||||
@@ -351,6 +352,21 @@ function onProductFocus(row) {
|
|||||||
ensureOptions(row);
|
ensureOptions(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 产品名称输入建议:对当前分类已加载的产品做本地模糊过滤(自由输入同样生效) */
|
||||||
|
function queryProducts(row, query, cb) {
|
||||||
|
const kw = String(query || "").trim().toLowerCase();
|
||||||
|
const list = (productOptions.value || []).filter(
|
||||||
|
(p) => !kw || String(p.product_name || "").toLowerCase().includes(kw)
|
||||||
|
);
|
||||||
|
// 附加 value 字段:兼容 autocomplete 默认以 value 作为选中值
|
||||||
|
cb(list.slice(0, 50).map((p) => ({ ...p, value: p.product_name })));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 名称失焦:命中产品库则带出规格/单位/单价/成本;未命中则标记为新增,保存时由后端自动建档 */
|
||||||
|
function onProductNameCommit(row) {
|
||||||
|
onProductPick(row, String(row.name || "").trim());
|
||||||
|
}
|
||||||
|
|
||||||
/** 按分类加载产品选项(本地 filterable 即可做到可写文字 + 模糊搜索,allow-create 支持自定义新增) */
|
/** 按分类加载产品选项(本地 filterable 即可做到可写文字 + 模糊搜索,allow-create 支持自定义新增) */
|
||||||
async function ensureOptions(row) {
|
async function ensureOptions(row) {
|
||||||
const cat = row.category;
|
const cat = row.category;
|
||||||
|
|||||||
@@ -611,6 +611,13 @@ const saveContract = async ({ finish = false }) => {
|
|||||||
ElMessage.warning("请先补全合同信息必填项");
|
ElMessage.warning("请先补全合同信息必填项");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// 未命名的产品行无法计价、也不会写入产品管理,完成保存前必须补全
|
||||||
|
const unnamed = products.value.filter((r) => !String(r?.name || "").trim()).length;
|
||||||
|
if (unnamed) {
|
||||||
|
activeStep.value = 1;
|
||||||
|
ElMessage.warning(`产品清单有 ${unnamed} 行未填写产品名称,请补全后再保存`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<!-- ============================ 回款进度 ============================ -->
|
<!-- ============================ 回款进度(回款记录流水) ============================ -->
|
||||||
<div class="section-head progress-head">
|
<div class="section-head progress-head">
|
||||||
<span class="section-title">回款进度</span>
|
<span class="section-title">回款进度</span>
|
||||||
<div class="progress-summary">
|
<div class="progress-summary">
|
||||||
@@ -57,49 +57,29 @@
|
|||||||
<span>已回款:<b class="success">¥{{ formatMoney(progressSummary.total_received) }}</b></span>
|
<span>已回款:<b class="success">¥{{ formatMoney(progressSummary.total_received) }}</b></span>
|
||||||
<span>待回款:<b class="warning">¥{{ formatMoney((progressSummary.total_planned || 0) - (progressSummary.total_received || 0)) }}</b></span>
|
<span>待回款:<b class="warning">¥{{ formatMoney((progressSummary.total_planned || 0) - (progressSummary.total_received || 0)) }}</b></span>
|
||||||
</div>
|
</div>
|
||||||
<el-tooltip :disabled="!paidOff" content="回款已全部完成(进度 100%),不可再新建回款计划" placement="top">
|
<el-tooltip :disabled="list.length > 0" content="回款进度以回款计划为先导条件,请先新建回款计划" placement="top">
|
||||||
<span class="btn-wrap">
|
<span class="btn-wrap">
|
||||||
<el-button type="primary" size="small" :icon="Plus" :disabled="paidOff" @click="openCreate">
|
<el-button type="primary" size="small" :icon="Plus" :disabled="!list.length" @click="openReceive">
|
||||||
新建回款计划
|
新建回款
|
||||||
</el-button>
|
</el-button>
|
||||||
</span>
|
</span>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="progressLoading" :data="progressList" border size="small" row-key="rowKey">
|
<el-table v-loading="progressLoading" :data="records" border size="small" row-key="id">
|
||||||
<el-table-column label="回款周期" width="90" align="center">
|
|
||||||
<template #default="{ row }">{{ paybackCycleText(row.plan_type) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="明细" min-width="140" show-overflow-tooltip>
|
|
||||||
<template #default="{ row }">{{ row.name || `第${row.seq}期` }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="进度百分比" width="100" align="center">
|
|
||||||
<template #default="{ row }">{{ row.percent ? `${row.percent}%` : "-" }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="计划金额" width="120" align="right">
|
|
||||||
<template #default="{ row }">{{ formatMoney(row.amount) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="计划日期" width="110" align="center">
|
|
||||||
<template #default="{ row }">{{ row.plan_date || "-" }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="已回款" width="120" align="right">
|
|
||||||
<template #default="{ row }">{{ formatMoney(row.received_amount) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="回款日期" width="110" align="center">
|
<el-table-column label="回款日期" width="110" align="center">
|
||||||
<template #default="{ row }">{{ (row.received_date && row.received_date !== "<nil>") ? row.received_date : "-" }}</template>
|
<template #default="{ row }">{{ row.received_date || "-" }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="逾期" width="100" align="center">
|
<el-table-column label="对应回款计划" min-width="180" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">{{ planText(planOf(row.payback_id)) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="本次回款金额" width="130" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag v-if="paybackItemOverdue(row).overdue" type="danger" size="small" effect="dark">
|
<span class="amount">¥{{ formatMoney(row.amount) }}</span>
|
||||||
{{ overdueText(paybackItemOverdue(row).days) }}
|
|
||||||
</el-tag>
|
|
||||||
<span v-else>-</span>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="状态" width="100" align="center">
|
<el-table-column label="回款方式" width="100" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">{{ paybackMethodText(row.pay_method) }}</template>
|
||||||
<el-tag :type="paybackItemStatusTag(row.status)" size="small">{{ paybackItemStatusText(row.status) }}</el-tag>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="流水回执" width="110" align="center">
|
<el-table-column label="流水回执" width="110" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -107,16 +87,16 @@
|
|||||||
<span v-else>-</span>
|
<span v-else>-</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="负责人" width="90">
|
<el-table-column label="备注" min-width="120" show-overflow-tooltip>
|
||||||
<template #default="{ row }">{{ row.owner_user_name || "-" }}</template>
|
<template #default="{ row }">{{ row.remark || "-" }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="100" align="center" fixed="right">
|
<el-table-column label="操作" width="80" align="center" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button link type="primary" size="small" @click="openReceive(row)">回款登记</el-button>
|
<el-button link type="danger" size="small" @click="handleDeleteRecord(row)">删除</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<template #empty>
|
<template #empty>
|
||||||
<el-empty description="该合同暂无回款进度,先新建回款计划" :image-size="60" />
|
<el-empty description="暂无回款记录,点击右上角「新建回款」登记一笔实收" :image-size="60" />
|
||||||
</template>
|
</template>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
@@ -131,21 +111,26 @@
|
|||||||
<!-- 回款计划详情 -->
|
<!-- 回款计划详情 -->
|
||||||
<PaybackDetail v-model:visible="detailVisible" :payback="currentRow" @edit="handleEdit" />
|
<PaybackDetail v-model:visible="detailVisible" :payback="currentRow" @edit="handleEdit" />
|
||||||
|
|
||||||
<!-- 回款登记 -->
|
<!-- 新建回款(回款进度登记:以回款计划为先导) -->
|
||||||
<el-dialog v-model="receiveVisible" title="回款登记" width="440px">
|
<el-dialog v-model="receiveVisible" title="新建回款" width="460px">
|
||||||
<el-form :model="receiveForm" label-width="90px">
|
<el-form :model="receiveForm" label-width="90px">
|
||||||
<el-form-item label="明细">
|
<el-form-item label="回款计划" required>
|
||||||
<span>{{ receiveForm.name || `第${receiveForm.seq}期` }}</span>
|
<el-select
|
||||||
|
v-model="receiveForm.payback_id"
|
||||||
|
placeholder="请选择回款计划"
|
||||||
|
style="width: 100%"
|
||||||
|
@change="onPlanChange"
|
||||||
|
>
|
||||||
|
<el-option v-for="p in list" :key="p.id" :label="planText(p)" :value="p.id" />
|
||||||
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="计划金额">
|
<el-form-item label="本次回款" required>
|
||||||
<span class="amount">¥{{ formatMoney(receiveForm.amount) }}</span>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="实收金额">
|
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-model="receiveForm.received_amount"
|
v-model="receiveForm.amount"
|
||||||
:min="0"
|
:min="0"
|
||||||
:precision="2"
|
:precision="2"
|
||||||
:controls="false"
|
:controls="false"
|
||||||
|
placeholder="0.00"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -158,6 +143,11 @@
|
|||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="回款方式">
|
||||||
|
<el-select v-model="receiveForm.pay_method" style="width: 100%">
|
||||||
|
<el-option v-for="m in PAYBACK_METHOD_OPTIONS" :key="m.value" :label="m.label" :value="m.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="流水回执">
|
<el-form-item label="流水回执">
|
||||||
<div class="receipt-block">
|
<div class="receipt-block">
|
||||||
<el-upload
|
<el-upload
|
||||||
@@ -177,6 +167,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="备注">
|
||||||
|
<el-input v-model="receiveForm.remark" type="textarea" :rows="2" maxlength="200" placeholder="选填" />
|
||||||
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="receiveVisible = false">取消</el-button>
|
<el-button @click="receiveVisible = false">取消</el-button>
|
||||||
@@ -188,21 +181,23 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, reactive } from "vue";
|
import { computed, ref, reactive } from "vue";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { Plus, Upload, Document, Delete } from "@element-plus/icons-vue";
|
import { Plus, Upload, Document, Delete } from "@element-plus/icons-vue";
|
||||||
import { getPaybackList, getPaybackProgress, registerPaybackReceive } from "@/api/crmPayback";
|
import {
|
||||||
|
getPaybackList,
|
||||||
|
getPaybackRecordList,
|
||||||
|
createPaybackRecord,
|
||||||
|
deletePaybackRecord,
|
||||||
|
} from "@/api/crmPayback";
|
||||||
import { uploadFile } from "@/api/file";
|
import { uploadFile } from "@/api/file";
|
||||||
import PaybackCreate from "../../payback/components/create.vue";
|
import PaybackCreate from "../../payback/components/create.vue";
|
||||||
import PaybackDetail from "../../payback/components/detail.vue";
|
import PaybackDetail from "../../payback/components/detail.vue";
|
||||||
import {
|
import {
|
||||||
|
PAYBACK_METHOD_OPTIONS,
|
||||||
paybackCycleText,
|
paybackCycleText,
|
||||||
paybackMethodText,
|
paybackMethodText,
|
||||||
paybackStatusText,
|
paybackStatusText,
|
||||||
paybackStatusTag,
|
paybackStatusTag,
|
||||||
paybackItemStatusText,
|
|
||||||
paybackItemStatusTag,
|
|
||||||
paybackItemOverdue,
|
|
||||||
overdueText,
|
|
||||||
formatMoney,
|
formatMoney,
|
||||||
} from "../../dict";
|
} from "../../dict";
|
||||||
|
|
||||||
@@ -220,11 +215,32 @@ const detailVisible = ref(false);
|
|||||||
const currentRow = ref(null);
|
const currentRow = ref(null);
|
||||||
const editRow = ref(null);
|
const editRow = ref(null);
|
||||||
|
|
||||||
/** 回款进度(摊平的分期明细) */
|
/** 回款进度:一笔笔实际回款记录(回款流水) */
|
||||||
const progressLoading = ref(false);
|
const progressLoading = ref(false);
|
||||||
const progressList = ref([]);
|
const records = ref([]);
|
||||||
const progressSummary = ref({ total_planned: 0, total_received: 0 });
|
const progressSummary = ref({ total_planned: 0, total_received: 0 });
|
||||||
|
|
||||||
|
/** 今日日期(新建回款默认回款日期) */
|
||||||
|
function todayStr() {
|
||||||
|
const d = new Date();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(d.getDate()).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按计划 id 取回款计划对象(回款记录上只存 payback_id) */
|
||||||
|
function planOf(paybackId) {
|
||||||
|
return list.value.find((i) => Number(i.id) === Number(paybackId)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 回款计划展示文案:周期 + 计划金额 + 已回款 */
|
||||||
|
function planText(p) {
|
||||||
|
if (!p) return "-";
|
||||||
|
return `${paybackCycleText(p.plan_type)}|计划 ¥${formatMoney(p.total_amount)}|已回 ¥${formatMoney(
|
||||||
|
p.received_amount
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** 回款计划是否已全部收齐(总进度达到 100%),收齐后禁止再新建回款计划 */
|
/** 回款计划是否已全部收齐(总进度达到 100%),收齐后禁止再新建回款计划 */
|
||||||
const paidOff = computed(() => {
|
const paidOff = computed(() => {
|
||||||
const planned = Number(progressSummary.value.total_planned) || 0;
|
const planned = Number(progressSummary.value.total_planned) || 0;
|
||||||
@@ -271,7 +287,7 @@ async function reload() {
|
|||||||
const contractId = props.contract?.id;
|
const contractId = props.contract?.id;
|
||||||
if (!contractId) {
|
if (!contractId) {
|
||||||
list.value = [];
|
list.value = [];
|
||||||
progressList.value = [];
|
records.value = [];
|
||||||
progressSummary.value = { total_planned: 0, total_received: 0 };
|
progressSummary.value = { total_planned: 0, total_received: 0 };
|
||||||
emit("count", 0);
|
emit("count", 0);
|
||||||
return;
|
return;
|
||||||
@@ -286,23 +302,23 @@ async function reload() {
|
|||||||
loading.value = false;
|
loading.value = false;
|
||||||
emit("count", list.value.length);
|
emit("count", list.value.length);
|
||||||
}
|
}
|
||||||
await fetchProgress();
|
await fetchRecords();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 拉取本合同回款进度 */
|
/** 拉取本合同回款记录(回款进度流水,一笔一条) */
|
||||||
async function fetchProgress() {
|
async function fetchRecords() {
|
||||||
const contractId = props.contract?.id;
|
const contractId = props.contract?.id;
|
||||||
if (!contractId) return;
|
if (!contractId) return;
|
||||||
progressLoading.value = true;
|
progressLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await getPaybackProgress({ contract_id: contractId });
|
const res = await getPaybackRecordList({ contract_id: contractId });
|
||||||
progressList.value = (res?.data?.list || []).map((r) => ({ ...r, rowKey: `${r.payback_id}_${r.seq}` }));
|
records.value = res?.data?.list || [];
|
||||||
progressSummary.value = {
|
progressSummary.value = {
|
||||||
total_planned: res?.data?.total_planned || 0,
|
total_planned: res?.data?.total_planned || 0,
|
||||||
total_received: res?.data?.total_received || 0,
|
total_received: res?.data?.total_received || 0,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
progressList.value = [];
|
records.value = [];
|
||||||
progressSummary.value = { total_planned: 0, total_received: 0 };
|
progressSummary.value = { total_planned: 0, total_received: 0 };
|
||||||
ElMessage.error(e.message || "查询回款进度失败");
|
ElMessage.error(e.message || "查询回款进度失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -310,34 +326,44 @@ async function fetchProgress() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------------------- 回款登记 ---------------------------- */
|
/* ---------------------------- 新建回款(回款进度登记) ---------------------------- */
|
||||||
const receiveVisible = ref(false);
|
const receiveVisible = ref(false);
|
||||||
const receiveSaving = ref(false);
|
const receiveSaving = ref(false);
|
||||||
const receiveForm = reactive({
|
const receiveForm = reactive({
|
||||||
payback_id: null,
|
payback_id: null,
|
||||||
seq: 0,
|
|
||||||
name: "",
|
|
||||||
amount: 0,
|
amount: 0,
|
||||||
received_amount: 0,
|
|
||||||
received_date: "",
|
received_date: "",
|
||||||
|
pay_method: 1,
|
||||||
receipt_url: "",
|
receipt_url: "",
|
||||||
receipt_name: "",
|
receipt_name: "",
|
||||||
|
remark: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
function openReceive(row) {
|
/** 新建回款:以回款计划为先导条件(无计划时不允许登记) */
|
||||||
|
function openReceive() {
|
||||||
|
if (!list.value.length) {
|
||||||
|
ElMessage.warning("请先新建回款计划,回款进度以计划为先导条件");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const first = list.value[0];
|
||||||
Object.assign(receiveForm, {
|
Object.assign(receiveForm, {
|
||||||
payback_id: row.payback_id,
|
payback_id: first.id,
|
||||||
seq: row.seq,
|
amount: 0,
|
||||||
name: row.name,
|
received_date: todayStr(),
|
||||||
amount: row.amount,
|
pay_method: first.pay_method || 1,
|
||||||
received_amount: Number(row.received_amount) || 0,
|
receipt_url: "",
|
||||||
received_date: row.received_date && row.received_date !== "<nil>" ? row.received_date : "",
|
receipt_name: "",
|
||||||
receipt_url: row.receipt_url || "",
|
remark: "",
|
||||||
receipt_name: row.receipt_name || "",
|
|
||||||
});
|
});
|
||||||
receiveVisible.value = true;
|
receiveVisible.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 切换回款计划:同步该计划的回款方式作为默认值 */
|
||||||
|
function onPlanChange(pid) {
|
||||||
|
const p = planOf(pid);
|
||||||
|
if (p) receiveForm.pay_method = p.pay_method || 1;
|
||||||
|
}
|
||||||
|
|
||||||
const uploading = ref(false);
|
const uploading = ref(false);
|
||||||
async function handleReceiptUpload(options) {
|
async function handleReceiptUpload(options) {
|
||||||
const file = options.file;
|
const file = options.file;
|
||||||
@@ -363,18 +389,28 @@ function clearReceipt() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitReceive() {
|
async function submitReceive() {
|
||||||
|
if (!receiveForm.payback_id) {
|
||||||
|
ElMessage.warning("请选择回款计划");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(Number(receiveForm.amount) > 0)) {
|
||||||
|
ElMessage.warning("请填写本次回款金额");
|
||||||
|
return;
|
||||||
|
}
|
||||||
receiveSaving.value = true;
|
receiveSaving.value = true;
|
||||||
try {
|
try {
|
||||||
await registerPaybackReceive(receiveForm.payback_id, {
|
await createPaybackRecord({
|
||||||
seq: receiveForm.seq,
|
payback_id: receiveForm.payback_id,
|
||||||
received_amount: Number(receiveForm.received_amount) || 0,
|
amount: Number(receiveForm.amount) || 0,
|
||||||
received_date: receiveForm.received_date || "",
|
received_date: receiveForm.received_date || "",
|
||||||
|
pay_method: receiveForm.pay_method,
|
||||||
receipt_url: receiveForm.receipt_url,
|
receipt_url: receiveForm.receipt_url,
|
||||||
receipt_name: receiveForm.receipt_name,
|
receipt_name: receiveForm.receipt_name,
|
||||||
|
remark: receiveForm.remark,
|
||||||
});
|
});
|
||||||
ElMessage.success("回款登记成功");
|
ElMessage.success("回款登记成功");
|
||||||
receiveVisible.value = false;
|
receiveVisible.value = false;
|
||||||
// 回款计划(含各期已回款金额、总进度)与回款进度明细一并刷新,无需重开抽屉
|
// 计划的已回款金额与回款进度流水一并刷新,无需重开抽屉
|
||||||
await reload();
|
await reload();
|
||||||
emit("saved");
|
emit("saved");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -384,6 +420,23 @@ async function submitReceive() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 删除一条回款记录(会同步回写计划的已回款金额) */
|
||||||
|
async function handleDeleteRecord(row) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定删除 ${row.received_date || "该"} 的这笔回款(¥${formatMoney(row.amount)})吗?`,
|
||||||
|
"删除确认",
|
||||||
|
{ type: "warning" }
|
||||||
|
);
|
||||||
|
await deletePaybackRecord(row.id);
|
||||||
|
ElMessage.success("删除成功");
|
||||||
|
await reload();
|
||||||
|
emit("saved");
|
||||||
|
} catch (e) {
|
||||||
|
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
defineExpose({ reload });
|
defineExpose({ reload });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
* - 软件部分 = Σ 产品类别为「软件(许可)」的小计
|
* - 软件部分 = Σ 产品类别为「软件(许可)」的小计
|
||||||
* - 其他部分 = Σ 服务 / 开发 / 其他类小计
|
* - 其他部分 = Σ 服务 / 开发 / 其他类小计
|
||||||
* - 合同总金额 = 硬件 + 软件 + 其他
|
* - 合同总金额 = 硬件 + 软件 + 其他
|
||||||
|
*
|
||||||
|
* 计税口径:清单中填写的单价即为「含税价」,小计 = 数量 × 单价,不再叠加税率;
|
||||||
|
* 税率字段仅作开票记录,不参与任何金额计算(前后端保持一致)。
|
||||||
* - 产品总成本 = Σ (数量 × 成本单价),软件开发行按其「开发总成本」计入
|
* - 产品总成本 = Σ (数量 × 成本单价),软件开发行按其「开发总成本」计入
|
||||||
* - 合同总利润 = 合同总金额 - 产品总成本
|
* - 合同总利润 = 合同总金额 - 产品总成本
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -35,10 +35,6 @@
|
|||||||
<el-descriptions-item label="已回款金额">
|
<el-descriptions-item label="已回款金额">
|
||||||
<span class="success-text">¥{{ formatMoney(detail.received_amount) }}</span>
|
<span class="success-text">¥{{ formatMoney(detail.received_amount) }}</span>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="流水回执">
|
|
||||||
<a v-if="detail.receipt_url" :href="detail.receipt_url" target="_blank" class="primary-text">{{ detail.receipt_name || "查看回执" }}</a>
|
|
||||||
<span v-else>-</span>
|
|
||||||
</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="提前提醒">
|
<el-descriptions-item label="提前提醒">
|
||||||
{{ detail.remind_days ? `${detail.remind_days} 天` : "-" }}
|
{{ detail.remind_days ? `${detail.remind_days} 天` : "-" }}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
@@ -46,74 +42,127 @@
|
|||||||
<el-descriptions-item label="备注" :span="2">{{ detail.remark || "-" }}</el-descriptions-item>
|
<el-descriptions-item label="备注" :span="2">{{ detail.remark || "-" }}</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
|
|
||||||
|
<!-- 回款明细:一笔笔实际回款记录 -->
|
||||||
<div class="detail-section-title">
|
<div class="detail-section-title">
|
||||||
<span>回款明细</span>
|
<span>回款明细</span>
|
||||||
<span class="section-tip">
|
<div class="section-right">
|
||||||
共 {{ items.length }} 期,已完成 {{ doneCount }} 期
|
<span class="section-tip">
|
||||||
</span>
|
共 {{ records.length }} 笔,累计 ¥{{ formatMoney(receivedSum) }}
|
||||||
|
</span>
|
||||||
|
<el-button type="primary" size="small" :icon="Plus" @click="openRecord">新建回款记录</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table :data="items" border size="small">
|
<el-table v-loading="recordLoading" :data="records" border size="small" row-key="id">
|
||||||
<el-table-column label="状态" width="100" align="center">
|
<el-table-column label="回款日期" width="120" align="center">
|
||||||
<template #default="{ row }">
|
|
||||||
<el-tag :type="paybackItemStatusTag(row.status)" size="small">
|
|
||||||
{{ paybackItemStatusText(row.status) }}
|
|
||||||
</el-tag>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="name" label="明细名称" min-width="130" show-overflow-tooltip />
|
|
||||||
<el-table-column label="计划日期" width="110" align="center">
|
|
||||||
<template #default="{ row }">{{ row.plan_date || "-" }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="回款进度" min-width="190">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<div class="progress-cell">
|
|
||||||
<div class="progress-top">
|
|
||||||
<span class="success-text">¥{{ formatMoney(row.received_amount) }}</span>
|
|
||||||
<span class="progress-sep">/</span>
|
|
||||||
<span>¥{{ formatMoney(row.amount) }}</span>
|
|
||||||
</div>
|
|
||||||
<el-progress
|
|
||||||
:percentage="paybackItemPercent(row)"
|
|
||||||
:stroke-width="10"
|
|
||||||
:color="paybackItemPercent(row) >= 100 ? 'var(--el-color-success)' : 'var(--el-color-primary)'"
|
|
||||||
:show-text="true"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="回款日期" width="110" align="center">
|
|
||||||
<template #default="{ row }">{{ row.received_date || "-" }}</template>
|
<template #default="{ row }">{{ row.received_date || "-" }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="逾期" width="100" align="center">
|
<el-table-column label="本次回款金额" min-width="140" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag v-if="paybackItemOverdue(row).overdue" type="danger" size="small" effect="dark">
|
<span class="success-text">¥{{ formatMoney(row.amount) }}</span>
|
||||||
{{ overdueText(paybackItemOverdue(row).days) }}
|
</template>
|
||||||
</el-tag>
|
</el-table-column>
|
||||||
|
<el-table-column label="回款方式" width="110" align="center">
|
||||||
|
<template #default="{ row }">{{ paybackMethodText(row.pay_method) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="流水回执" width="120" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link v-if="row.receipt_url" type="primary" :href="row.receipt_url" target="_blank" :underline="false">
|
||||||
|
查看回执
|
||||||
|
</el-link>
|
||||||
<span v-else>-</span>
|
<span v-else>-</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<template #empty><el-empty description="暂无回款明细" :image-size="60" /></template>
|
<el-table-column label="备注" min-width="150" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">{{ row.remark || "-" }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="80" align="center" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="danger" size="small" @click="handleDeleteRecord(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<el-empty description="暂无回款记录,点击右上角「新建回款记录」登记" :image-size="60" />
|
||||||
|
</template>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 新建回款记录(当前计划固定,无需再选) -->
|
||||||
|
<el-dialog v-model="recordVisible" title="新建回款记录" width="460px" append-to-body>
|
||||||
|
<el-form :model="recordForm" label-width="90px">
|
||||||
|
<el-form-item label="本次回款" required>
|
||||||
|
<el-input-number
|
||||||
|
v-model="recordForm.amount"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
:controls="false"
|
||||||
|
placeholder="0.00"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="回款日期">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="recordForm.received_date"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
placeholder="选择日期"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="回款方式">
|
||||||
|
<el-select v-model="recordForm.pay_method" style="width: 100%">
|
||||||
|
<el-option v-for="m in PAYBACK_METHOD_OPTIONS" :key="m.value" :label="m.label" :value="m.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="流水回执">
|
||||||
|
<div class="receipt-block">
|
||||||
|
<el-upload
|
||||||
|
:show-file-list="false"
|
||||||
|
:http-request="handleReceiptUpload"
|
||||||
|
:disabled="uploading"
|
||||||
|
accept=".jpg,.jpeg,.png,.pdf,.doc,.docx,.xls,.xlsx"
|
||||||
|
>
|
||||||
|
<el-button :icon="Upload" :loading="uploading" size="small">上传流水回执</el-button>
|
||||||
|
</el-upload>
|
||||||
|
<div v-if="recordForm.receipt_url" class="receipt-file">
|
||||||
|
<el-link type="primary" :href="recordForm.receipt_url" target="_blank" :underline="false">
|
||||||
|
<el-icon><Document /></el-icon>
|
||||||
|
<span class="receipt-name">{{ recordForm.receipt_name || "流水回执" }}</span>
|
||||||
|
</el-link>
|
||||||
|
<el-button link type="danger" size="small" :icon="Delete" @click="clearReceipt">移除</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注">
|
||||||
|
<el-input v-model="recordForm.remark" type="textarea" :rows="2" maxlength="200" placeholder="选填" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="recordVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="recordSaving" @click="submitRecord">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from "vue";
|
import { ref, reactive, computed } from "vue";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { Edit, Delete } from "@element-plus/icons-vue";
|
import { Edit, Delete, Plus, Upload, Document } from "@element-plus/icons-vue";
|
||||||
import { getPaybackDetail, deletePayback } from "@/api/crmPayback";
|
|
||||||
import {
|
import {
|
||||||
|
getPaybackDetail,
|
||||||
|
deletePayback,
|
||||||
|
getPaybackRecordList,
|
||||||
|
createPaybackRecord,
|
||||||
|
deletePaybackRecord,
|
||||||
|
} from "@/api/crmPayback";
|
||||||
|
import { uploadFile } from "@/api/file";
|
||||||
|
import {
|
||||||
|
PAYBACK_METHOD_OPTIONS,
|
||||||
paybackCycleText,
|
paybackCycleText,
|
||||||
paybackMethodText,
|
paybackMethodText,
|
||||||
paybackStatusText,
|
paybackStatusText,
|
||||||
paybackStatusTag,
|
paybackStatusTag,
|
||||||
paybackItemStatusText,
|
|
||||||
paybackItemStatusTag,
|
|
||||||
paybackPlanOverdue,
|
|
||||||
paybackItemOverdue,
|
|
||||||
overdueText,
|
|
||||||
formatMoney,
|
formatMoney,
|
||||||
} from "../../dict";
|
} from "../../dict";
|
||||||
|
|
||||||
@@ -122,50 +171,169 @@ const props = defineProps({
|
|||||||
payback: { type: Object, default: null },
|
payback: { type: Object, default: null },
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(["update:visible", "edit", "delete"]);
|
const emit = defineEmits(["update:visible", "edit", "delete", "refresh"]);
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const detail = ref({});
|
const detail = ref({});
|
||||||
|
|
||||||
const items = computed(() => (Array.isArray(detail.value.items) ? detail.value.items : []));
|
/* ---------------------------- 回款明细(回款记录流水) ---------------------------- */
|
||||||
const doneCount = computed(() => items.value.filter((i) => Number(i.status) === 3).length);
|
const recordLoading = ref(false);
|
||||||
|
const records = ref([]);
|
||||||
/** 回款明细进度百分比(已回款/计划金额,封顶 100 用于进度条显示) */
|
/** 已登记回款累计(与计划表的已回款金额一致) */
|
||||||
function paybackItemPercent(row) {
|
const receivedSum = computed(() =>
|
||||||
const amount = Number(row.amount) || 0;
|
records.value.reduce((sum, r) => sum + (Number(r.amount) || 0), 0)
|
||||||
const received = Number(row.received_amount) || 0;
|
);
|
||||||
if (amount <= 0) return 0;
|
|
||||||
const pct = Math.round((received / amount) * 100);
|
|
||||||
return pct > 100 ? 100 : pct;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 兼容 items 为 JSON 字符串 / 数组两种形态 */
|
|
||||||
function normalize(row) {
|
|
||||||
let list = row.items;
|
|
||||||
if (typeof list === "string" && list.trim()) {
|
|
||||||
try {
|
|
||||||
list = JSON.parse(list);
|
|
||||||
} catch {
|
|
||||||
list = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { ...row, items: Array.isArray(list) ? list : [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDetail() {
|
async function loadDetail() {
|
||||||
// 先用列表行数据回显,再拉取详情补充完整明细
|
// 先用列表行数据回显,再拉取详情补充完整信息
|
||||||
detail.value = normalize(props.payback || {});
|
detail.value = props.payback || {};
|
||||||
const id = props.payback?.id;
|
const id = props.payback?.id;
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await getPaybackDetail(id);
|
const res = await getPaybackDetail(id);
|
||||||
detail.value = normalize(res?.data || {});
|
detail.value = res?.data || detail.value;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 详情拉取失败时保留列表行数据
|
// 详情拉取失败时保留列表行数据
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
await fetchRecords();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拉取当前计划下的回款记录(一笔一条) */
|
||||||
|
async function fetchRecords() {
|
||||||
|
const id = props.payback?.id;
|
||||||
|
if (!id) {
|
||||||
|
records.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
recordLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getPaybackRecordList({ payback_id: id });
|
||||||
|
records.value = res?.data?.list || [];
|
||||||
|
} catch (e) {
|
||||||
|
records.value = [];
|
||||||
|
ElMessage.error(e.message || "查询回款记录失败");
|
||||||
|
} finally {
|
||||||
|
recordLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅刷新计划详情(拿最新的已回款金额与状态) */
|
||||||
|
async function reloadDetail() {
|
||||||
|
const id = props.payback?.id;
|
||||||
|
if (!id) return;
|
||||||
|
try {
|
||||||
|
const res = await getPaybackDetail(id);
|
||||||
|
if (res?.data) detail.value = res.data;
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------- 新建回款记录 ---------------------------- */
|
||||||
|
const recordVisible = ref(false);
|
||||||
|
const recordSaving = ref(false);
|
||||||
|
const recordForm = reactive({
|
||||||
|
amount: 0,
|
||||||
|
received_date: "",
|
||||||
|
pay_method: 1,
|
||||||
|
receipt_url: "",
|
||||||
|
receipt_name: "",
|
||||||
|
remark: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 今日日期(默认回款日期) */
|
||||||
|
function todayStr() {
|
||||||
|
const d = new Date();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(d.getDate()).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 打开新建回款记录(计划已固定为当前详情) */
|
||||||
|
function openRecord() {
|
||||||
|
Object.assign(recordForm, {
|
||||||
|
amount: 0,
|
||||||
|
received_date: todayStr(),
|
||||||
|
pay_method: detail.value.pay_method || 1,
|
||||||
|
receipt_url: "",
|
||||||
|
receipt_name: "",
|
||||||
|
remark: "",
|
||||||
|
});
|
||||||
|
recordVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploading = ref(false);
|
||||||
|
async function handleReceiptUpload(options) {
|
||||||
|
const file = options.file;
|
||||||
|
uploading.value = true;
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
const res = await uploadFile(fd);
|
||||||
|
const data = res?.data || {};
|
||||||
|
recordForm.receipt_url = data.url || "";
|
||||||
|
recordForm.receipt_name = data.name || file.name;
|
||||||
|
ElMessage.success("上传成功");
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.message || "上传失败");
|
||||||
|
} finally {
|
||||||
|
uploading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearReceipt() {
|
||||||
|
recordForm.receipt_url = "";
|
||||||
|
recordForm.receipt_name = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitRecord() {
|
||||||
|
if (!detail.value.id) return;
|
||||||
|
if (!(Number(recordForm.amount) > 0)) {
|
||||||
|
ElMessage.warning("请填写本次回款金额");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
recordSaving.value = true;
|
||||||
|
try {
|
||||||
|
await createPaybackRecord({
|
||||||
|
payback_id: detail.value.id,
|
||||||
|
amount: Number(recordForm.amount) || 0,
|
||||||
|
received_date: recordForm.received_date || "",
|
||||||
|
pay_method: recordForm.pay_method,
|
||||||
|
receipt_url: recordForm.receipt_url,
|
||||||
|
receipt_name: recordForm.receipt_name,
|
||||||
|
remark: recordForm.remark,
|
||||||
|
});
|
||||||
|
ElMessage.success("回款记录已创建");
|
||||||
|
recordVisible.value = false;
|
||||||
|
// 刷新明细与计划汇总(已回款金额、状态随之变化)
|
||||||
|
await fetchRecords();
|
||||||
|
await reloadDetail();
|
||||||
|
emit("refresh");
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.message || "创建失败");
|
||||||
|
} finally {
|
||||||
|
recordSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteRecord(row) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定删除 ${row.received_date || "该"} 的这笔回款(¥${formatMoney(row.amount)})吗?`,
|
||||||
|
"删除确认",
|
||||||
|
{ type: "warning" }
|
||||||
|
);
|
||||||
|
await deletePaybackRecord(row.id);
|
||||||
|
ElMessage.success("删除成功");
|
||||||
|
await fetchRecords();
|
||||||
|
await reloadDetail();
|
||||||
|
emit("refresh");
|
||||||
|
} catch (e) {
|
||||||
|
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
@@ -222,6 +390,12 @@ async function handleDelete() {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
.section-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.section-tip {
|
.section-tip {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
@@ -239,20 +413,17 @@ async function handleDelete() {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-cell {
|
.receipt-block {
|
||||||
display: flex;
|
width: 100%;
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
|
|
||||||
.progress-top {
|
.receipt-file {
|
||||||
|
margin-top: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 8px;
|
||||||
font-size: 12px;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
|
|
||||||
.progress-sep {
|
.receipt-name {
|
||||||
color: var(--el-text-color-placeholder);
|
margin-left: 4px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,360 +3,169 @@
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>回款管理</h2>
|
<h2>回款管理</h2>
|
||||||
<p>针对于合同的回款计划创建与回款进度跟踪</p>
|
<p>针对于合同的回款计划创建与回款明细跟踪(点击「详情」登记一笔笔回款)</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<el-button :icon="Refresh" @click="refreshAll">刷新</el-button>
|
<el-button :icon="Refresh" @click="fetchList">刷新</el-button>
|
||||||
<el-button type="primary" :icon="Plus" @click="openCreate">新建回款计划</el-button>
|
<el-button type="primary" :icon="Plus" @click="openCreate">新建回款计划</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-tabs v-model="activeTab" class="payback-tabs" @tab-change="handleTabChange">
|
<div class="stat-cards">
|
||||||
<el-tab-pane label="回款计划" name="plan" />
|
<div class="stat-card">
|
||||||
<el-tab-pane label="回款进度" name="progress" />
|
<span class="stat-label">回款计划数</span>
|
||||||
</el-tabs>
|
<span class="stat-value">{{ pagination.total }}</span>
|
||||||
|
|
||||||
<!-- ============================ 回款计划 ============================ -->
|
|
||||||
<template v-if="activeTab === 'plan'">
|
|
||||||
<div class="stat-cards">
|
|
||||||
<div class="stat-card">
|
|
||||||
<span class="stat-label">回款计划数</span>
|
|
||||||
<span class="stat-value">{{ pagination.total }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<span class="stat-label">计划回款总额</span>
|
|
||||||
<span class="stat-value primary">¥{{ formatMoney(summary.total_amount) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<span class="stat-label">已回款金额</span>
|
|
||||||
<span class="stat-value success">¥{{ formatMoney(summary.received_amount) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<span class="stat-label">待回款金额</span>
|
|
||||||
<span class="stat-value warning">¥{{ formatMoney(summary.pending_amount) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
<div class="filter-bar">
|
<span class="stat-label">计划回款总额</span>
|
||||||
<el-form :inline="true" :model="filters" @submit.prevent>
|
<span class="stat-value primary">¥{{ formatMoney(summary.total_amount) }}</span>
|
||||||
<el-form-item>
|
|
||||||
<el-input
|
|
||||||
v-model="filters.keyword"
|
|
||||||
clearable
|
|
||||||
placeholder="搜索合同 / 客户 / 负责人"
|
|
||||||
:prefix-icon="Search"
|
|
||||||
style="width: 240px"
|
|
||||||
@keyup.enter="handleSearch"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="回款周期">
|
|
||||||
<el-select v-model="filters.plan_type" clearable placeholder="全部" style="width: 110px">
|
|
||||||
<el-option v-for="i in PAYBACK_CYCLE_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="状态">
|
|
||||||
<el-select v-model="filters.status" clearable placeholder="全部" style="width: 110px">
|
|
||||||
<el-option v-for="i in PAYBACK_STATUS_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item>
|
|
||||||
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
|
|
||||||
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">已回款金额</span>
|
||||||
|
<span class="stat-value success">¥{{ formatMoney(summary.received_amount) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">待回款金额</span>
|
||||||
|
<span class="stat-value warning">¥{{ formatMoney(summary.pending_amount) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="table-container" v-loading="loading">
|
<div class="filter-bar">
|
||||||
<el-table :data="tableData" stripe border row-key="id">
|
<el-form :inline="true" :model="filters" @submit.prevent>
|
||||||
<el-table-column label="状态" width="90" align="center" fixed>
|
<el-form-item>
|
||||||
<template #default="{ row }">
|
<el-input
|
||||||
<el-tag :type="paybackStatusTag(row.status)" size="small">{{ paybackStatusText(row.status) }}</el-tag>
|
v-model="filters.keyword"
|
||||||
</template>
|
clearable
|
||||||
</el-table-column>
|
placeholder="搜索合同 / 客户 / 负责人"
|
||||||
<el-table-column label="合同名称" min-width="180" show-overflow-tooltip fixed>
|
:prefix-icon="Search"
|
||||||
<template #default="{ row }">
|
style="width: 240px"
|
||||||
<span class="name-link" @click="openDetail(row)">{{ row.contract_name || "-" }}</span>
|
@keyup.enter="handleSearch"
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="客户名称" min-width="150" show-overflow-tooltip>
|
|
||||||
<template #default="{ row }">{{ row.customer_name || "-" }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="回款周期" width="90" align="center">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-tag size="small" effect="plain">{{ paybackCycleText(row.plan_type) }}</el-tag>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="回款方式" width="110" align="center">
|
|
||||||
<template #default="{ row }">{{ paybackMethodText(row.pay_method) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="合同总金额" width="130" align="right">
|
|
||||||
<template #default="{ row }">{{ formatMoney(row.contract_amount) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="计划回款总额" width="130" align="right">
|
|
||||||
<template #default="{ row }">{{ formatMoney(row.total_amount) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="已回款" width="120" align="right">
|
|
||||||
<template #default="{ row }">{{ formatMoney(row.received_amount) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="回款进度" width="150" align="center">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-progress :percentage="paybackPercent(row)" :stroke-width="10" />
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="负责人" width="90">
|
|
||||||
<template #default="{ row }">{{ row.owner_user_name || "-" }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="提前提醒" width="90" align="center">
|
|
||||||
<template #default="{ row }">{{ row.remind_days ? `${row.remind_days}天` : "-" }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="逾期" width="100" align="center">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-tag v-if="paybackPlanOverdue(row).overdue" type="danger" size="small" effect="dark">{{ overdueText(paybackPlanOverdue(row).days) }}</el-tag>
|
|
||||||
<span v-else>-</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<template #empty><el-empty description="暂无回款计划" :image-size="80" /></template>
|
|
||||||
</el-table>
|
|
||||||
|
|
||||||
<div class="pagination">
|
|
||||||
<el-pagination
|
|
||||||
v-model:current-page="pagination.page"
|
|
||||||
v-model:page-size="pagination.pageSize"
|
|
||||||
:total="pagination.total"
|
|
||||||
:page-sizes="[10, 20, 50, 100]"
|
|
||||||
layout="total, sizes, prev, pager, next, jumper"
|
|
||||||
@size-change="handleSearch"
|
|
||||||
@current-change="fetchList"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</el-form-item>
|
||||||
</div>
|
<el-form-item label="回款周期">
|
||||||
</template>
|
<el-select v-model="filters.plan_type" clearable placeholder="全部" style="width: 110px">
|
||||||
|
<el-option v-for="i in PAYBACK_CYCLE_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="filters.status" clearable placeholder="全部" style="width: 110px">
|
||||||
|
<el-option v-for="i in PAYBACK_STATUS_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
|
||||||
|
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ============================ 回款进度 ============================ -->
|
<div class="table-container" v-loading="loading">
|
||||||
<template v-else>
|
<el-table :data="tableData" stripe border row-key="id">
|
||||||
<div class="stat-cards">
|
<el-table-column label="状态" width="90" align="center" fixed>
|
||||||
<div class="stat-card">
|
<template #default="{ row }">
|
||||||
<span class="stat-label">分期总数</span>
|
<el-tag :type="paybackStatusTag(row.status)" size="small">{{ paybackStatusText(row.status) }}</el-tag>
|
||||||
<span class="stat-value">{{ progressList.length }}</span>
|
</template>
|
||||||
</div>
|
</el-table-column>
|
||||||
<div class="stat-card">
|
<el-table-column label="逾期" width="120" align="center" fixed>
|
||||||
<span class="stat-label">计划回款总额</span>
|
<template #default="{ row }">
|
||||||
<span class="stat-value primary">¥{{ formatMoney(progressSummary.total_planned) }}</span>
|
<el-tag v-if="paybackPlanOverdue(row).overdue" type="danger" size="small" effect="dark">
|
||||||
</div>
|
{{ overdueText(paybackPlanOverdue(row).days) }}
|
||||||
<div class="stat-card">
|
</el-tag>
|
||||||
<span class="stat-label">已回款金额</span>
|
<span v-else>-</span>
|
||||||
<span class="stat-value success">¥{{ formatMoney(progressSummary.total_received) }}</span>
|
</template>
|
||||||
</div>
|
</el-table-column>
|
||||||
<div class="stat-card">
|
<el-table-column label="合同名称" min-width="180" show-overflow-tooltip fixed>
|
||||||
<span class="stat-label">待回款金额</span>
|
<template #default="{ row }">
|
||||||
<span class="stat-value warning">
|
<span class="name-link" @click="openDetail(row)">{{ row.contract_name || "-" }}</span>
|
||||||
¥{{ formatMoney((progressSummary.total_planned || 0) - (progressSummary.total_received || 0)) }}
|
</template>
|
||||||
</span>
|
</el-table-column>
|
||||||
</div>
|
<el-table-column label="客户名称" min-width="150" show-overflow-tooltip>
|
||||||
</div>
|
<template #default="{ row }">{{ row.customer_name || "-" }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="回款周期" width="90" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" effect="plain">{{ paybackCycleText(row.plan_type) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="回款方式" width="110" align="center">
|
||||||
|
<template #default="{ row }">{{ paybackMethodText(row.pay_method) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="合同总金额" width="130" align="right">
|
||||||
|
<template #default="{ row }">{{ formatMoney(row.contract_amount) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="计划回款总额" width="130" align="right">
|
||||||
|
<template #default="{ row }">{{ formatMoney(row.total_amount) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="已回款" width="120" align="right">
|
||||||
|
<template #default="{ row }">{{ formatMoney(row.received_amount) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="回款进度" width="150" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-progress :percentage="paybackPercent(row)" :stroke-width="10" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="负责人" width="90">
|
||||||
|
<template #default="{ row }">{{ row.owner_user_name || "-" }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="提前提醒" width="90" align="center">
|
||||||
|
<template #default="{ row }">{{ row.remind_days ? `${row.remind_days}天` : "-" }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="160" align="center" class-name="op-col" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button>
|
||||||
|
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||||
|
<el-button link type="danger" size="small" @click="handleRowDelete(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template #empty><el-empty description="暂无回款计划" :image-size="80" /></template>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
<div class="filter-bar">
|
<div class="pagination">
|
||||||
<el-form :inline="true" :model="progressFilters" @submit.prevent>
|
<el-pagination
|
||||||
<el-form-item>
|
v-model:current-page="pagination.page"
|
||||||
<el-input
|
v-model:page-size="pagination.pageSize"
|
||||||
v-model="progressFilters.keyword"
|
:total="pagination.total"
|
||||||
clearable
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
placeholder="搜索合同 / 客户 / 负责人"
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
:prefix-icon="Search"
|
@size-change="handleSearch"
|
||||||
style="width: 240px"
|
@current-change="fetchList"
|
||||||
@keyup.enter="fetchProgress"
|
/>
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="回款状态">
|
|
||||||
<el-select v-model="progressFilters.status" clearable placeholder="全部" style="width: 120px">
|
|
||||||
<el-option v-for="i in PAYBACK_ITEM_STATUS_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item>
|
|
||||||
<el-button type="primary" :icon="Search" @click="fetchProgress">查询</el-button>
|
|
||||||
<el-button :icon="Refresh" @click="resetProgressFilters">重置</el-button>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="table-container" v-loading="progressLoading">
|
|
||||||
<el-table :data="pagedProgress" stripe border row-key="rowKey">
|
|
||||||
<el-table-column label="回款周期" width="90" align="center" fixed>
|
|
||||||
<template #default="{ row }">{{ paybackCycleText(row.plan_type) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="明细" min-width="140" show-overflow-tooltip>
|
|
||||||
<template #default="{ row }">{{ row.name || `第${row.seq}期` }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="进度百分比" width="100" align="center">
|
|
||||||
<template #default="{ row }">{{ row.percent ? `${row.percent}%` : "-" }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="计划金额" width="120" align="right">
|
|
||||||
<template #default="{ row }">{{ formatMoney(row.amount) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="计划日期" width="110" align="center">
|
|
||||||
<template #default="{ row }">{{ row.plan_date || "-" }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="已回款" width="120" align="right">
|
|
||||||
<template #default="{ row }">{{ formatMoney(row.received_amount) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="回款日期" width="110" align="center">
|
|
||||||
<template #default="{ row }">{{ (row.received_date && row.received_date !== "<nil>") ? row.received_date : "-" }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="合同名称" min-width="180" show-overflow-tooltip >
|
|
||||||
<template #default="{ row }">{{ row.contract_name || "-" }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="客户名称" min-width="140" show-overflow-tooltip>
|
|
||||||
<template #default="{ row }">{{ row.customer_name || "-" }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="逾期" width="100" align="center">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-tag v-if="paybackItemOverdue(row).overdue" type="danger" size="small" effect="dark">
|
|
||||||
{{ overdueText(paybackItemOverdue(row).days) }}
|
|
||||||
</el-tag>
|
|
||||||
<span v-else>-</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="状态" width="100" align="center">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-tag :type="paybackItemStatusTag(row.status)" size="small">
|
|
||||||
{{ paybackItemStatusText(row.status) }}
|
|
||||||
</el-tag>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="流水回执" width="110" align="center">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-link v-if="row.receipt_url" type="primary" :href="row.receipt_url" target="_blank" :underline="false">查看回执</el-link>
|
|
||||||
<span v-else>-</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="负责人" width="90">
|
|
||||||
<template #default="{ row }">{{ row.owner_user_name || "-" }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="操作" width="100" align="center" class-name="op-col" fixed="right">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-button link type="primary" size="small" @click="openReceive(row)">回款登记</el-button>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<template #empty><el-empty description="暂无回款进度" :image-size="80" /></template>
|
|
||||||
</el-table>
|
|
||||||
|
|
||||||
<div class="pagination">
|
|
||||||
<el-pagination
|
|
||||||
v-model:current-page="progressPagination.page"
|
|
||||||
v-model:page-size="progressPagination.pageSize"
|
|
||||||
:total="progressList.length"
|
|
||||||
:page-sizes="[10, 20, 50, 100]"
|
|
||||||
layout="total, sizes, prev, pager, next"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 新建 / 编辑回款计划 -->
|
<!-- 新建 / 编辑回款计划 -->
|
||||||
<PaybackCreate
|
<PaybackCreate v-model:visible="createVisible" :edit-data="currentRow" @success="fetchList" />
|
||||||
v-model:visible="createVisible"
|
|
||||||
:edit-data="currentRow"
|
|
||||||
@success="refreshAll"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 回款计划详情 -->
|
<!-- 回款计划详情:下方为回款明细(一笔条回款记录),可新建回款记录 -->
|
||||||
<PaybackDetail
|
<PaybackDetail
|
||||||
v-model:visible="detailVisible"
|
v-model:visible="detailVisible"
|
||||||
:payback="currentRow"
|
:payback="currentRow"
|
||||||
@edit="handleDetailEdit"
|
@edit="handleDetailEdit"
|
||||||
@delete="handleDetailDelete"
|
@delete="handleDetailDelete"
|
||||||
|
@refresh="fetchList"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 回款登记 -->
|
|
||||||
<el-dialog v-model="receiveVisible" title="回款登记" width="440px">
|
|
||||||
<el-form :model="receiveForm" label-width="90px">
|
|
||||||
<el-form-item label="合同名称">
|
|
||||||
<span>{{ receiveForm.contract_name || "-" }}</span>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="明细">
|
|
||||||
<span>{{ receiveForm.name || `第${receiveForm.seq}期` }}</span>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="计划金额">
|
|
||||||
<span class="amount">¥{{ formatMoney(receiveForm.amount) }}</span>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="实收金额">
|
|
||||||
<el-input-number
|
|
||||||
v-model="receiveForm.received_amount"
|
|
||||||
:min="0"
|
|
||||||
:precision="2"
|
|
||||||
:controls="false"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="回款日期">
|
|
||||||
<el-date-picker
|
|
||||||
v-model="receiveForm.received_date"
|
|
||||||
type="date"
|
|
||||||
value-format="YYYY-MM-DD"
|
|
||||||
placeholder="选择日期"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="流水回执">
|
|
||||||
<div class="receipt-block">
|
|
||||||
<el-upload
|
|
||||||
:show-file-list="false"
|
|
||||||
:http-request="handleReceiptUpload"
|
|
||||||
:disabled="uploading"
|
|
||||||
accept=".jpg,.jpeg,.png,.pdf,.doc,.docx,.xls,.xlsx"
|
|
||||||
>
|
|
||||||
<el-button :icon="Upload" :loading="uploading" size="small">上传流水回执</el-button>
|
|
||||||
</el-upload>
|
|
||||||
<div v-if="receiveForm.receipt_url" class="receipt-file">
|
|
||||||
<el-link type="primary" :href="receiveForm.receipt_url" target="_blank" :underline="false">
|
|
||||||
<el-icon><Document /></el-icon>
|
|
||||||
<span class="receipt-name">{{ receiveForm.receipt_name || "流水回执" }}</span>
|
|
||||||
</el-link>
|
|
||||||
<el-button link type="danger" size="small" :icon="Delete" @click="clearReceipt">移除</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
<template #footer>
|
|
||||||
<el-button @click="receiveVisible = false">取消</el-button>
|
|
||||||
<el-button type="primary" :loading="receiveSaving" @click="submitReceive">保存</el-button>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, computed, onMounted } from "vue";
|
import { ref, reactive, onMounted } from "vue";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { Plus, Search, Refresh, Upload, Document, Delete } from "@element-plus/icons-vue";
|
import { Plus, Search, Refresh } from "@element-plus/icons-vue";
|
||||||
import {
|
import { getPaybackList, deletePayback } from "@/api/crmPayback";
|
||||||
getPaybackList,
|
|
||||||
deletePayback,
|
|
||||||
getPaybackProgress,
|
|
||||||
registerPaybackReceive,
|
|
||||||
} from "@/api/crmPayback";
|
|
||||||
import { uploadFile } from "@/api/file";
|
|
||||||
import PaybackCreate from "./components/create.vue";
|
import PaybackCreate from "./components/create.vue";
|
||||||
import PaybackDetail from "./components/detail.vue";
|
import PaybackDetail from "./components/detail.vue";
|
||||||
import {
|
import {
|
||||||
PAYBACK_CYCLE_OPTIONS,
|
PAYBACK_CYCLE_OPTIONS,
|
||||||
PAYBACK_STATUS_OPTIONS,
|
PAYBACK_STATUS_OPTIONS,
|
||||||
PAYBACK_ITEM_STATUS_OPTIONS,
|
|
||||||
paybackCycleText,
|
paybackCycleText,
|
||||||
paybackMethodText,
|
paybackMethodText,
|
||||||
paybackStatusText,
|
paybackStatusText,
|
||||||
paybackStatusTag,
|
paybackStatusTag,
|
||||||
paybackItemStatusText,
|
|
||||||
paybackItemStatusTag,
|
|
||||||
paybackPlanOverdue,
|
paybackPlanOverdue,
|
||||||
paybackItemOverdue,
|
|
||||||
overdueText,
|
overdueText,
|
||||||
formatMoney,
|
formatMoney,
|
||||||
} from "../dict";
|
} from "../dict";
|
||||||
|
|
||||||
const activeTab = ref("plan");
|
|
||||||
|
|
||||||
/* ---------------------------- 回款计划 ---------------------------- */
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const tableData = ref([]);
|
const tableData = ref([]);
|
||||||
const createVisible = ref(false);
|
const createVisible = ref(false);
|
||||||
@@ -407,121 +216,6 @@ function paybackPercent(row) {
|
|||||||
return Math.min(100, Math.round(p));
|
return Math.min(100, Math.round(p));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------------------- 回款进度 ---------------------------- */
|
|
||||||
const progressLoading = ref(false);
|
|
||||||
const progressList = ref([]);
|
|
||||||
const progressSummary = ref({ total_planned: 0, total_received: 0 });
|
|
||||||
const progressFilters = reactive({ keyword: "", status: "" });
|
|
||||||
const progressPagination = reactive({ page: 1, pageSize: 20 });
|
|
||||||
|
|
||||||
const pagedProgress = computed(() => {
|
|
||||||
const start = (progressPagination.page - 1) * progressPagination.pageSize;
|
|
||||||
return progressList.value.slice(start, start + progressPagination.pageSize).map((r) => ({
|
|
||||||
...r,
|
|
||||||
rowKey: `${r.payback_id}_${r.seq}`,
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
async function fetchProgress() {
|
|
||||||
progressLoading.value = true;
|
|
||||||
try {
|
|
||||||
const res = await getPaybackProgress({ ...progressFilters });
|
|
||||||
progressList.value = (res?.data?.list || []).map((r) => ({ ...r, rowKey: `${r.payback_id}_${r.seq}` }));
|
|
||||||
progressSummary.value = {
|
|
||||||
total_planned: res?.data?.total_planned || 0,
|
|
||||||
total_received: res?.data?.total_received || 0,
|
|
||||||
};
|
|
||||||
progressPagination.page = 1;
|
|
||||||
} catch (e) {
|
|
||||||
progressList.value = [];
|
|
||||||
progressSummary.value = { total_planned: 0, total_received: 0 };
|
|
||||||
ElMessage.error(e.message || "查询失败");
|
|
||||||
} finally {
|
|
||||||
progressLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetProgressFilters() {
|
|
||||||
progressFilters.keyword = "";
|
|
||||||
progressFilters.status = "";
|
|
||||||
fetchProgress();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------- 回款登记 ---------------------------- */
|
|
||||||
const receiveVisible = ref(false);
|
|
||||||
const receiveSaving = ref(false);
|
|
||||||
const receiveForm = reactive({
|
|
||||||
payback_id: null,
|
|
||||||
seq: 0,
|
|
||||||
name: "",
|
|
||||||
contract_name: "",
|
|
||||||
amount: 0,
|
|
||||||
received_amount: 0,
|
|
||||||
received_date: "",
|
|
||||||
receipt_url: "",
|
|
||||||
receipt_name: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
function openReceive(row) {
|
|
||||||
Object.assign(receiveForm, {
|
|
||||||
payback_id: row.payback_id,
|
|
||||||
seq: row.seq,
|
|
||||||
name: row.name,
|
|
||||||
contract_name: row.contract_name,
|
|
||||||
amount: row.amount,
|
|
||||||
received_amount: Number(row.received_amount) || 0,
|
|
||||||
received_date: (row.received_date && row.received_date !== "<nil>") ? row.received_date : "",
|
|
||||||
receipt_url: row.receipt_url || "",
|
|
||||||
receipt_name: row.receipt_name || "",
|
|
||||||
});
|
|
||||||
receiveVisible.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 上传流水回执(非必填):复用通用文件上传接口 */
|
|
||||||
const uploading = ref(false);
|
|
||||||
async function handleReceiptUpload(options) {
|
|
||||||
const file = options.file;
|
|
||||||
uploading.value = true;
|
|
||||||
try {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append("file", file);
|
|
||||||
const res = await uploadFile(fd);
|
|
||||||
const data = res?.data || {};
|
|
||||||
receiveForm.receipt_url = data.url || "";
|
|
||||||
receiveForm.receipt_name = data.name || file.name;
|
|
||||||
ElMessage.success("上传成功");
|
|
||||||
} catch (e) {
|
|
||||||
ElMessage.error(e.message || "上传失败");
|
|
||||||
} finally {
|
|
||||||
uploading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearReceipt() {
|
|
||||||
receiveForm.receipt_url = "";
|
|
||||||
receiveForm.receipt_name = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitReceive() {
|
|
||||||
receiveSaving.value = true;
|
|
||||||
try {
|
|
||||||
await registerPaybackReceive(receiveForm.payback_id, {
|
|
||||||
seq: receiveForm.seq,
|
|
||||||
received_amount: Number(receiveForm.received_amount) || 0,
|
|
||||||
received_date: receiveForm.received_date || "",
|
|
||||||
receipt_url: receiveForm.receipt_url,
|
|
||||||
receipt_name: receiveForm.receipt_name,
|
|
||||||
});
|
|
||||||
ElMessage.success("回款登记成功");
|
|
||||||
receiveVisible.value = false;
|
|
||||||
fetchProgress();
|
|
||||||
} catch (e) {
|
|
||||||
ElMessage.error(e.message || "登记失败");
|
|
||||||
} finally {
|
|
||||||
receiveSaving.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------- 通用操作 ---------------------------- */
|
/* ---------------------------- 通用操作 ---------------------------- */
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
currentRow.value = null;
|
currentRow.value = null;
|
||||||
@@ -544,8 +238,14 @@ function handleDetailEdit(row) {
|
|||||||
openEdit(row);
|
openEdit(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 详情内点击删除:删除回款计划并刷新列表 */
|
/** 详情内删除(详情组件已完成确认与删除):此处只需关闭并刷新列表 */
|
||||||
async function handleDetailDelete(row) {
|
function handleDetailDelete() {
|
||||||
|
detailVisible.value = false;
|
||||||
|
fetchList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表行删除:确认后删除并刷新 */
|
||||||
|
async function handleRowDelete(row) {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
`确定删除合同「${row.contract_name}」的回款计划吗?删除后不可恢复。`,
|
`确定删除合同「${row.contract_name}」的回款计划吗?删除后不可恢复。`,
|
||||||
@@ -554,37 +254,18 @@ async function handleDetailDelete(row) {
|
|||||||
);
|
);
|
||||||
await deletePayback(row.id);
|
await deletePayback(row.id);
|
||||||
ElMessage.success("删除成功");
|
ElMessage.success("删除成功");
|
||||||
detailVisible.value = false;
|
fetchList();
|
||||||
refreshAll();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
|
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTabChange(name) {
|
|
||||||
if (name === "progress") fetchProgress();
|
|
||||||
else fetchList();
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshAll() {
|
|
||||||
if (activeTab.value === "progress") fetchProgress();
|
|
||||||
else fetchList();
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(fetchList);
|
onMounted(fetchList);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="less" scoped src="../styles/crm-page.less"></style>
|
<style lang="less" scoped src="../styles/crm-page.less"></style>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.payback-tabs {
|
|
||||||
margin-bottom: 4px;
|
|
||||||
|
|
||||||
:deep(.el-tabs__header) {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-cards {
|
.stat-cards {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
@@ -625,33 +306,6 @@ onMounted(fetchList);
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.amount {
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--el-color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 流水回执上传 */
|
|
||||||
.receipt-block {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 12px;
|
|
||||||
|
|
||||||
.receipt-file {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
|
|
||||||
.receipt-name {
|
|
||||||
margin-left: 4px;
|
|
||||||
max-width: 220px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
::deep(.el-table .op-col .cell) {
|
::deep(.el-table .op-col .cell) {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -531,7 +531,8 @@ func contractStatusName(s int8) string {
|
|||||||
|
|
||||||
// ========================== 内部辅助 ==========================
|
// ========================== 内部辅助 ==========================
|
||||||
|
|
||||||
// buildContractResp 组装响应:parties / products 透传 JSON 数组,summary 由金额字段组装。
|
// buildContractResp 组装响应:parties / products 透传 JSON 数组;
|
||||||
|
// 金额字段在返回前按 products 以当前口径重算(单价含税、不再叠加税率),summary 随之组装。
|
||||||
func buildContractResp(row *models.TenantCrmContract) contractResp {
|
func buildContractResp(row *models.TenantCrmContract) contractResp {
|
||||||
parties := json.RawMessage("[]")
|
parties := json.RawMessage("[]")
|
||||||
if strings.TrimSpace(row.Parties) != "" {
|
if strings.TrimSpace(row.Parties) != "" {
|
||||||
@@ -541,6 +542,11 @@ func buildContractResp(row *models.TenantCrmContract) contractResp {
|
|||||||
if strings.TrimSpace(row.Products) != "" {
|
if strings.TrimSpace(row.Products) != "" {
|
||||||
products = json.RawMessage(row.Products)
|
products = json.RawMessage(row.Products)
|
||||||
}
|
}
|
||||||
|
// 返回前按「单价含税、不再叠加税率」的口径重算金额,
|
||||||
|
// 保证列表/详情与产品清单一致,并自动修正历史数据按旧口径(叠加税率)存的金额
|
||||||
|
if strings.TrimSpace(row.Products) != "" && string(products) != "[]" {
|
||||||
|
applyContractAmounts(row)
|
||||||
|
}
|
||||||
return contractResp{
|
return contractResp{
|
||||||
TenantCrmContract: *row,
|
TenantCrmContract: *row,
|
||||||
Parties: parties,
|
Parties: parties,
|
||||||
@@ -584,9 +590,9 @@ func normalizeContractJSON(raw json.RawMessage) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// applyContractAmounts 按产品清单重算各部分金额(与前端 ProductList 汇总口径一致):
|
// applyContractAmounts 按产品清单重算各部分金额(与前端 ProductList 汇总口径一致):
|
||||||
// 每行含税金额=数量×单价×(1+税率/100);
|
// 清单单价为含税价,每行小计=数量×单价(不再叠加税率,税率仅作开票记录);
|
||||||
// 硬件部分=Σ硬件含税小计;软件部分=Σ软件含税小计;其他部分=Σ服务/开发/其他含税小计;
|
// 硬件部分=Σ硬件小计;软件部分=Σ软件小计;其他部分=Σ服务/开发/其他小计;
|
||||||
// 合同总金额(含税)=硬件+软件+其他;产品总成本=Σ(数量×成本单价)(成本通常不含税,原值汇总);
|
// 合同总金额=硬件+软件+其他;产品总成本=Σ(数量×成本单价)(成本为不含税原值);
|
||||||
// 合同总利润=总金额-总成本。
|
// 合同总利润=总金额-总成本。
|
||||||
// 归类口径:分类代码 1/2 或名称含「硬件」/「软件」分别计入硬件/软件,其余计入其他。
|
// 归类口径:分类代码 1/2 或名称含「硬件」/「软件」分别计入硬件/软件,其余计入其他。
|
||||||
// contractTreeSellTotal 软件开发模块树售价合计:子节点汇总,叶子=人天×统一人天单价。
|
// contractTreeSellTotal 软件开发模块树售价合计:子节点汇总,叶子=人天×统一人天单价。
|
||||||
@@ -642,8 +648,8 @@ func applyContractAmounts(row *models.TenantCrmContract) {
|
|||||||
qty := toFloat64(item["quantity"])
|
qty := toFloat64(item["quantity"])
|
||||||
price := toFloat64(item["price"])
|
price := toFloat64(item["price"])
|
||||||
costPrice := toFloat64(item["cost_price"])
|
costPrice := toFloat64(item["cost_price"])
|
||||||
taxRate := toFloat64(item["tax_rate"])
|
// 清单单价为含税价:小计 = 数量 × 单价,不再叠加税率(税率仅作开票记录,不参与金额计算)
|
||||||
amount := round2(qty * price * (1 + taxRate/100)) // 含税金额
|
amount := round2(qty * price)
|
||||||
cost = round2(cost + round2(qty*costPrice))
|
cost = round2(cost + round2(qty*costPrice))
|
||||||
cat := fmt.Sprintf("%v", item["category"])
|
cat := fmt.Sprintf("%v", item["category"])
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -135,7 +135,8 @@ func (c *BackendCrmPaybackController) List() {
|
|||||||
total, _ := qs.Count()
|
total, _ := qs.Count()
|
||||||
var list []models.TenantCrmPayback
|
var list []models.TenantCrmPayback
|
||||||
if total > 0 {
|
if total > 0 {
|
||||||
_, _ = qs.OrderBy("-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
|
// 排序:进行中(1) 在前、已完成(2) 在后,同状态内按最新创建优先
|
||||||
|
_, _ = qs.OrderBy("status", "-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
|
||||||
}
|
}
|
||||||
items := make([]paybackResp, 0, len(list))
|
items := make([]paybackResp, 0, len(list))
|
||||||
for i := range list {
|
for i := range list {
|
||||||
@@ -644,3 +645,188 @@ func (c *BackendCrmPaybackController) fillFromContract(tenantID string, contract
|
|||||||
func formatAmount(n float64) string {
|
func formatAmount(n float64) string {
|
||||||
return strconv.FormatFloat(round2(n), 'f', 2, 64)
|
return strconv.FormatFloat(round2(n), 'f', 2, 64)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================== 回款记录(回款进度流水) ==========================
|
||||||
|
//
|
||||||
|
// 以「回款计划」为先导条件:每次「新建回款」追加一条记录,构成回款进度列表(一笔笔实际到账);
|
||||||
|
// 计划表的 received_amount / status 由本表金额汇总回写(refreshPlanReceived)。
|
||||||
|
|
||||||
|
// RecordList GET /backend/crm/payback/record/list
|
||||||
|
// 回款进度:按合同 / 计划返回一笔笔实际回款记录,并给出计划总额与已回款汇总。
|
||||||
|
func (c *BackendCrmPaybackController) RecordList() {
|
||||||
|
claims, err := pipelineClaims(&c.Controller)
|
||||||
|
if err != nil {
|
||||||
|
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tenantID := pipelineTenantID(claims)
|
||||||
|
contractID, _ := c.GetUint64("contract_id")
|
||||||
|
paybackID, _ := c.GetUint64("payback_id")
|
||||||
|
|
||||||
|
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
|
||||||
|
if contractID > 0 {
|
||||||
|
cond = cond.And("contract_id", contractID)
|
||||||
|
}
|
||||||
|
if paybackID > 0 {
|
||||||
|
cond = cond.And("payback_id", paybackID)
|
||||||
|
}
|
||||||
|
list := make([]models.TenantCrmPaybackRecord, 0)
|
||||||
|
_, _ = models.Orm.QueryTable(new(models.TenantCrmPaybackRecord)).
|
||||||
|
SetCond(cond).OrderBy("-received_date", "-id").All(&list)
|
||||||
|
|
||||||
|
// 汇总:回款计划的计划总额与已回款;带 contract_id 时只统计该合同,否则统计全部
|
||||||
|
// (已回款由回款记录汇总回写,兼容历史计划)
|
||||||
|
var totalPlanned, totalReceived float64
|
||||||
|
planCond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
|
||||||
|
if contractID > 0 {
|
||||||
|
planCond = planCond.And("contract_id", contractID)
|
||||||
|
}
|
||||||
|
var plans []models.TenantCrmPayback
|
||||||
|
_, _ = models.Orm.QueryTable(new(models.TenantCrmPayback)).SetCond(planCond).All(&plans)
|
||||||
|
for _, p := range plans {
|
||||||
|
totalPlanned = round2(totalPlanned + p.TotalAmount)
|
||||||
|
totalReceived = round2(totalReceived + p.ReceivedAmount)
|
||||||
|
}
|
||||||
|
pipelineOk(&c.Controller, map[string]interface{}{
|
||||||
|
"list": list, "total": len(list),
|
||||||
|
"total_planned": totalPlanned, "total_received": totalReceived,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRecord POST /backend/crm/payback/record
|
||||||
|
// 新建回款:以回款计划为先导,追加一条实际回款记录,并回写计划的已回款金额与状态。
|
||||||
|
func (c *BackendCrmPaybackController) CreateRecord() {
|
||||||
|
claims, err := pipelineClaims(&c.Controller)
|
||||||
|
if err != nil {
|
||||||
|
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||||
|
var p struct {
|
||||||
|
PaybackID uint64 `json:"payback_id"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
ReceivedDate string `json:"received_date"`
|
||||||
|
PayMethod int8 `json:"pay_method"`
|
||||||
|
ReceiptURL string `json:"receipt_url"`
|
||||||
|
ReceiptName string `json:"receipt_name"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &p); err != nil {
|
||||||
|
pipelineErr(&c.Controller, 400, 400, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.PaybackID == 0 {
|
||||||
|
pipelineErr(&c.Controller, 400, 400, "请先选择回款计划")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if round2(p.Amount) <= 0 {
|
||||||
|
pipelineErr(&c.Controller, 400, 400, "请填写本次回款金额")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tenantID := pipelineTenantID(claims)
|
||||||
|
// 回款计划必须存在且属于当前租户(回款进度以计划为先导条件)
|
||||||
|
var plan models.TenantCrmPayback
|
||||||
|
if err := models.Orm.QueryTable(new(models.TenantCrmPayback)).
|
||||||
|
Filter("id", p.PaybackID).Filter("tenant_id", tenantID).
|
||||||
|
Filter("delete_time__isnull", true).One(&plan); err != nil {
|
||||||
|
pipelineErr(&c.Controller, 404, 404, "回款计划不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payMethod := p.PayMethod
|
||||||
|
if payMethod <= 0 {
|
||||||
|
payMethod = plan.PayMethod
|
||||||
|
}
|
||||||
|
var contractID *uint64
|
||||||
|
if plan.ContractID != nil {
|
||||||
|
cid := *plan.ContractID
|
||||||
|
contractID = &cid
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
row := models.TenantCrmPaybackRecord{
|
||||||
|
TenantID: tenantID,
|
||||||
|
ContractID: contractID,
|
||||||
|
PaybackID: plan.ID,
|
||||||
|
Amount: round2(p.Amount),
|
||||||
|
ReceivedDate: strings.TrimSpace(p.ReceivedDate),
|
||||||
|
PayMethod: payMethod,
|
||||||
|
ReceiptURL: strings.TrimSpace(p.ReceiptURL),
|
||||||
|
ReceiptName: strings.TrimSpace(p.ReceiptName),
|
||||||
|
Remark: strings.TrimSpace(p.Remark),
|
||||||
|
OwnerUserID: plan.OwnerUserID,
|
||||||
|
OwnerUserName: plan.OwnerUserName,
|
||||||
|
CreateUserID: pipelineUID(claims),
|
||||||
|
CreateTime: now,
|
||||||
|
UpdateTime: now,
|
||||||
|
}
|
||||||
|
id, ierr := models.Orm.Insert(&row)
|
||||||
|
if ierr != nil {
|
||||||
|
pipelineErr(&c.Controller, 500, 500, "保存失败: "+ierr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refreshPlanReceived(tenantID, plan.ID)
|
||||||
|
if contractID != nil {
|
||||||
|
crmWriteLog(tenantID, 3, *contractID, "payback",
|
||||||
|
fmt.Sprintf("新建回款:%s 元(%s)", formatAmount(row.Amount), row.ReceivedDate), claims)
|
||||||
|
}
|
||||||
|
pipelineOk(&c.Controller, map[string]interface{}{"id": id})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRecord DELETE /backend/crm/payback/record/:id
|
||||||
|
// 删除一条回款记录(软删除),并回写计划的已回款金额与状态。
|
||||||
|
func (c *BackendCrmPaybackController) DeleteRecord() {
|
||||||
|
claims, err := pipelineClaims(&c.Controller)
|
||||||
|
if err != nil {
|
||||||
|
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||||
|
if id == 0 {
|
||||||
|
pipelineErr(&c.Controller, 400, 400, "无效的ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tenantID := pipelineTenantID(claims)
|
||||||
|
var row models.TenantCrmPaybackRecord
|
||||||
|
if err := models.Orm.QueryTable(new(models.TenantCrmPaybackRecord)).
|
||||||
|
Filter("id", id).Filter("tenant_id", tenantID).
|
||||||
|
Filter("delete_time__isnull", true).One(&row); err != nil {
|
||||||
|
pipelineErr(&c.Controller, 404, 404, "回款记录不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if _, err := models.Orm.QueryTable(new(models.TenantCrmPaybackRecord)).
|
||||||
|
Filter("id", id).Filter("tenant_id", tenantID).
|
||||||
|
Update(orm.Params{"delete_time": now, "update_time": now}); err != nil {
|
||||||
|
pipelineErr(&c.Controller, 500, 500, "删除失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refreshPlanReceived(tenantID, row.PaybackID)
|
||||||
|
pipelineOk(&c.Controller, map[string]interface{}{"id": id})
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshPlanReceived 按回款记录重算计划的已回款金额,并同步计划状态(1进行中 / 2已完成)。
|
||||||
|
func refreshPlanReceived(tenantID string, paybackID uint64) {
|
||||||
|
if paybackID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var rows []orm.Params
|
||||||
|
sql := "SELECT IFNULL(SUM(amount),0) AS received FROM " +
|
||||||
|
new(models.TenantCrmPaybackRecord).TableName() +
|
||||||
|
" WHERE tenant_id = ? AND payback_id = ? AND delete_time IS NULL"
|
||||||
|
if _, err := models.Orm.Raw(sql, tenantID, paybackID).Values(&rows); err != nil || len(rows) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
received := round2(toFloat64(rows[0]["received"]))
|
||||||
|
var plan models.TenantCrmPayback
|
||||||
|
if err := models.Orm.QueryTable(new(models.TenantCrmPayback)).
|
||||||
|
Filter("id", paybackID).Filter("tenant_id", tenantID).
|
||||||
|
Filter("delete_time__isnull", true).One(&plan); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status := int8(1)
|
||||||
|
if plan.TotalAmount > 0 && received+0.005 >= plan.TotalAmount {
|
||||||
|
status = 2
|
||||||
|
}
|
||||||
|
_, _ = models.Orm.QueryTable(new(models.TenantCrmPayback)).
|
||||||
|
Filter("id", paybackID).Filter("tenant_id", tenantID).
|
||||||
|
Update(orm.Params{"received_amount": received, "status": status, "update_time": time.Now()})
|
||||||
|
}
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ func Init(_ string) {
|
|||||||
new(TenantCrmOperateLog),
|
new(TenantCrmOperateLog),
|
||||||
new(TenantCrmContract),
|
new(TenantCrmContract),
|
||||||
new(TenantCrmPayback),
|
new(TenantCrmPayback),
|
||||||
|
new(TenantCrmPaybackRecord),
|
||||||
new(ErpAccountSet),
|
new(ErpAccountSet),
|
||||||
new(ErpNormalSetting),
|
new(ErpNormalSetting),
|
||||||
new(ErpCompanyContact),
|
new(ErpCompanyContact),
|
||||||
@@ -151,6 +152,7 @@ func Init(_ string) {
|
|||||||
EnsureCrmContractTechDateColumn()
|
EnsureCrmContractTechDateColumn()
|
||||||
EnsureCrmProjectProductsColumn()
|
EnsureCrmProjectProductsColumn()
|
||||||
EnsureCrmPaybackTable()
|
EnsureCrmPaybackTable()
|
||||||
|
EnsureCrmPaybackRecordTable()
|
||||||
EnsureCrmProductDevColumns()
|
EnsureCrmProductDevColumns()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +195,37 @@ func EnsureCrmPaybackTable() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EnsureCrmPaybackRecordTable 回款记录表建表(CREATE TABLE IF NOT EXISTS,可重复执行)。
|
||||||
|
// 回款记录 = 回款进度流水:每次「新建回款」追加一条,以回款计划(payback_id)为先导条件。
|
||||||
|
func EnsureCrmPaybackRecordTable() {
|
||||||
|
sql := `CREATE TABLE IF NOT EXISTS yz_backend_crm_payback_record (
|
||||||
|
id bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
|
||||||
|
tenant_id varchar(64) NOT NULL COMMENT '租户ID',
|
||||||
|
contract_id bigint(20) DEFAULT NULL COMMENT '关联合同ID(yz_backend_crm_contract.id)',
|
||||||
|
payback_id bigint(20) NOT NULL DEFAULT '0' COMMENT '关联回款计划ID(yz_backend_crm_payback.id)',
|
||||||
|
amount decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '本次回款金额',
|
||||||
|
received_date varchar(20) NOT NULL DEFAULT '' COMMENT '回款日期 YYYY-MM-DD',
|
||||||
|
pay_method tinyint(4) NOT NULL DEFAULT '1' COMMENT '回款方式:1对公/2网银/3现金/4支票/5支付宝/6微信/7其他',
|
||||||
|
receipt_url varchar(512) DEFAULT NULL COMMENT '流水回执文件地址',
|
||||||
|
receipt_name varchar(255) DEFAULT NULL COMMENT '流水回执文件名',
|
||||||
|
remark text COMMENT '备注',
|
||||||
|
owner_user_id varchar(64) NOT NULL DEFAULT '' COMMENT '负责人用户ID',
|
||||||
|
owner_user_name varchar(128) NOT NULL DEFAULT '' COMMENT '负责人姓名',
|
||||||
|
create_user_id varchar(64) NOT NULL DEFAULT '' COMMENT '创建人用户ID',
|
||||||
|
create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
delete_time datetime DEFAULT NULL COMMENT '删除时间(软删除)',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_tenant_contract (tenant_id,contract_id),
|
||||||
|
KEY idx_payback (tenant_id,payback_id),
|
||||||
|
KEY idx_delete_time (delete_time)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户CRM回款记录表(回款进度流水)'`
|
||||||
|
if _, err := Orm.Raw(sql).Exec(); err != nil {
|
||||||
|
// 表已存在或执行失败时忽略
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// EnsureCrmContractOurRoleColumn 补齐合同表的我方角色字段(存量表已建时新增;
|
// EnsureCrmContractOurRoleColumn 补齐合同表的我方角色字段(存量表已建时新增;
|
||||||
// 旧版「合同性质 contract_nature」字段弃用但保留不动,表不存在或列已存在时忽略错误)。
|
// 旧版「合同性质 contract_nature」字段弃用但保留不动,表不存在或列已存在时忽略错误)。
|
||||||
func EnsureCrmContractOurRoleColumn() {
|
func EnsureCrmContractOurRoleColumn() {
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// TenantCrmPaybackRecord 回款记录(回款进度流水): yz_backend_crm_payback_record
|
||||||
|
//
|
||||||
|
// 说明:
|
||||||
|
// - 以「回款计划」为先导:payback_id 关联 yz_backend_crm_payback.id;
|
||||||
|
// contract_id 冗余存储,便于按合同直接查询进度列表;
|
||||||
|
// - 每次「新建回款」追加一条记录,构成回款进度(一笔笔实际到账),
|
||||||
|
// 与计划内的 items(计划分期)区分开:前者是实际发生,后者是计划安排;
|
||||||
|
// - 计划表 received_amount 由本表记录金额汇总回写(refreshPlanReceived)。
|
||||||
|
type TenantCrmPaybackRecord struct {
|
||||||
|
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||||
|
TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
|
||||||
|
ContractID *uint64 `orm:"column(contract_id);null" json:"contract_id"` // 关联合同ID
|
||||||
|
PaybackID uint64 `orm:"column(payback_id)" json:"payback_id"` // 关联回款计划ID
|
||||||
|
Amount float64 `orm:"column(amount);digits(14);decimals(2);default(0)" json:"amount"` // 本次回款金额
|
||||||
|
ReceivedDate string `orm:"column(received_date);size(20)" json:"received_date"` // 回款日期 YYYY-MM-DD
|
||||||
|
PayMethod int8 `orm:"column(pay_method);default(1)" json:"pay_method"` // 回款方式:1对公/2网银/3现金/4支票/5支付宝/6微信/7其他
|
||||||
|
ReceiptURL string `orm:"column(receipt_url);size(512);null" json:"receipt_url"` // 流水回执文件地址
|
||||||
|
ReceiptName string `orm:"column(receipt_name);size(255);null" json:"receipt_name"` // 流水回执文件名
|
||||||
|
Remark string `orm:"column(remark);type(text);null" json:"remark"` // 备注
|
||||||
|
OwnerUserID string `orm:"column(owner_user_id);size(64)" json:"owner_user_id"`
|
||||||
|
OwnerUserName string `orm:"column(owner_user_name);size(128)" json:"owner_user_name"`
|
||||||
|
CreateUserID string `orm:"column(create_user_id);size(64)" json:"create_user_id"`
|
||||||
|
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||||
|
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
|
||||||
|
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *TenantCrmPaybackRecord) TableName() string {
|
||||||
|
return "yz_backend_crm_payback_record"
|
||||||
|
}
|
||||||
@@ -429,6 +429,10 @@ func registerOrganizationRoutes(module string) {
|
|||||||
// CRM回款管理(针对于合同:回款计划创建 + 回款进度登记)
|
// CRM回款管理(针对于合同:回款计划创建 + 回款进度登记)
|
||||||
beego.Router("/backend/crm/payback/list", &controllers.BackendCrmPaybackController{}, "get:List")
|
beego.Router("/backend/crm/payback/list", &controllers.BackendCrmPaybackController{}, "get:List")
|
||||||
beego.Router("/backend/crm/payback/progress/list", &controllers.BackendCrmPaybackController{}, "get:ProgressList")
|
beego.Router("/backend/crm/payback/progress/list", &controllers.BackendCrmPaybackController{}, "get:ProgressList")
|
||||||
|
// 回款记录(回款进度流水):以回款计划为先导,逐笔登记实际回款
|
||||||
|
beego.Router("/backend/crm/payback/record/list", &controllers.BackendCrmPaybackController{}, "get:RecordList")
|
||||||
|
beego.Router("/backend/crm/payback/record", &controllers.BackendCrmPaybackController{}, "post:CreateRecord")
|
||||||
|
beego.Router("/backend/crm/payback/record/:id", &controllers.BackendCrmPaybackController{}, "delete:DeleteRecord")
|
||||||
beego.Router("/backend/crm/payback", &controllers.BackendCrmPaybackController{}, "post:Create")
|
beego.Router("/backend/crm/payback", &controllers.BackendCrmPaybackController{}, "post:Create")
|
||||||
beego.Router("/backend/crm/payback/:id", &controllers.BackendCrmPaybackController{}, "get:Detail;put:Update;delete:Delete")
|
beego.Router("/backend/crm/payback/:id", &controllers.BackendCrmPaybackController{}, "get:Detail;put:Update;delete:Delete")
|
||||||
beego.Router("/backend/crm/payback/:id/receive", &controllers.BackendCrmPaybackController{}, "post:Receive")
|
beego.Router("/backend/crm/payback/:id/receive", &controllers.BackendCrmPaybackController{}, "post:Receive")
|
||||||
|
|||||||
Reference in New Issue
Block a user