优化项目管理
This commit is contained in:
@@ -454,7 +454,7 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
/** 选择签订日期:生效日期为空时默认带入(生效日期仍可手动修改,改过则不再被覆盖) */
|
||||
/** 签订日期兜底(编辑历史数据/程序赋值):生效日期为空时带入签订日期 */
|
||||
watch(
|
||||
() => form.sign_date,
|
||||
(nv) => {
|
||||
@@ -462,9 +462,13 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
/** 签订日期变更(用户操作):自动带入生效日期 */
|
||||
/**
|
||||
* 签订日期变更(用户操作):生效日期始终同步为签订日期。
|
||||
* 生效日期此后仍可单独调整;但签订日期再次变化时会重新同步,
|
||||
* 保持「生效日期默认等于签订日期」的口径。
|
||||
*/
|
||||
function onSignDateChange(val) {
|
||||
if (val && !form.effective_date) form.effective_date = val;
|
||||
if (val) form.effective_date = val;
|
||||
}
|
||||
|
||||
/** 合同形式文案:2双方 3三方 4四方 */
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<div class="contract-payback">
|
||||
<div class="section-head">
|
||||
<span class="section-title">回款计划</span>
|
||||
<!-- 回款已收齐(进度 100%)时不可再新建回款计划 -->
|
||||
<el-tooltip :disabled="!paidOff" content="回款已全部完成(进度 100%),不可再新建回款计划" placement="top">
|
||||
<span class="btn-wrap">
|
||||
<el-button type="primary" size="small" :icon="Plus" :disabled="paidOff" @click="openCreate">
|
||||
@@ -88,9 +89,18 @@
|
||||
<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>
|
||||
</div>
|
||||
<el-tooltip :disabled="list.length > 0" content="回款进度以回款计划为先导条件,请先新建回款计划" placement="top">
|
||||
<!-- 无回款计划时不可登记(先导条件);回款已收齐(100%)时同样不可再新建 -->
|
||||
<el-tooltip
|
||||
:disabled="canCreateReceive"
|
||||
:content="
|
||||
list.length
|
||||
? '回款已全部完成(进度 100%),不可再新建回款'
|
||||
: '回款进度以回款计划为先导条件,请先新建回款计划'
|
||||
"
|
||||
placement="top"
|
||||
>
|
||||
<span class="btn-wrap">
|
||||
<el-button type="primary" size="small" :icon="Plus" :disabled="!list.length" @click="openReceive()">
|
||||
<el-button type="primary" size="small" :icon="Plus" :disabled="!canCreateReceive" @click="openReceive()">
|
||||
新建回款
|
||||
</el-button>
|
||||
</span>
|
||||
@@ -115,15 +125,15 @@
|
||||
<el-table-column label="回款方式" width="100" align="center">
|
||||
<template #default="{ row }">{{ paybackMethodText(row.pay_method) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="流水回执" width="110" align="center">
|
||||
<el-table-column label="备注" min-width="120" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.remark || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="流水回执" width="110" align="center" fixed="right">
|
||||
<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="备注" min-width="120" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.remark || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
@@ -135,23 +145,15 @@
|
||||
>
|
||||
补回执
|
||||
</el-button>
|
||||
<el-tooltip
|
||||
:disabled="Number((planOf(row.payback_id) || {}).status) !== 2"
|
||||
content="所属回款计划已完成(回款已收齐),不允许删除回款记录"
|
||||
placement="top"
|
||||
<!-- 所属回款计划已完成(回款已收齐)的记录不可删除,不显示按钮 -->
|
||||
<el-button
|
||||
v-if="Number((planOf(row.payback_id) || {}).status) !== 2"
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="handleDeleteRecord(row)"
|
||||
>删除</el-button
|
||||
>
|
||||
<span>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="Number((planOf(row.payback_id) || {}).status) === 2"
|
||||
@click="handleDeleteRecord(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
@@ -408,13 +410,16 @@ function paybackItemStatusTag(row) {
|
||||
return { 0: "info", 1: "warning", 2: "success" }[itemStatusOf(row)] || "info";
|
||||
}
|
||||
|
||||
/** 回款计划是否已全部收齐(总进度达到 100%),收齐后禁止再新建回款计划 */
|
||||
/** 回款计划是否已全部收齐(总进度达到 100%),收齐后不可再新建回款计划 / 回款 */
|
||||
const paidOff = computed(() => {
|
||||
const planned = Number(progressSummary.value.total_planned) || 0;
|
||||
if (planned <= 0) return false;
|
||||
return (Number(progressSummary.value.total_received) || 0) >= planned;
|
||||
});
|
||||
|
||||
/** 「新建回款」可用条件:已存在回款计划(先导条件)且尚未全部收齐(进度未到 100%) */
|
||||
const canCreateReceive = computed(() => list.value.length > 0 && !paidOff.value);
|
||||
|
||||
/** 回款进度百分比 */
|
||||
function paybackPercent(row) {
|
||||
const total = Number(row.total_amount) || 0;
|
||||
@@ -423,7 +428,7 @@ function paybackPercent(row) {
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
// 兜底拦截:回款已收齐时不允许再建计划
|
||||
// 兜底拦截:回款已全部收齐(进度 100%)时不允许再建计划
|
||||
if (paidOff.value) {
|
||||
ElMessage.warning("回款已全部完成(进度 100%),不可再新建回款计划");
|
||||
return;
|
||||
@@ -516,6 +521,11 @@ function openReceive() {
|
||||
ElMessage.warning("请先新建回款计划,回款进度以计划为先导条件");
|
||||
return;
|
||||
}
|
||||
// 兜底拦截:回款已全部收齐(进度 100%)时不允许再登记回款
|
||||
if (paidOff.value) {
|
||||
ElMessage.warning("回款已全部完成(进度 100%),不可再新建回款");
|
||||
return;
|
||||
}
|
||||
const first = list.value[0];
|
||||
Object.assign(receiveForm, {
|
||||
payback_id: first.id,
|
||||
@@ -653,7 +663,7 @@ async function submitReceive() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除一条回款记录(会同步回写计划的已回款金额;所属计划已完成时禁止) */
|
||||
/** 删除一条回款记录(后端同步重算计划的已回款金额与状态;所属计划已完成时禁止) */
|
||||
async function handleDeleteRecord(row) {
|
||||
if (Number((planOf(row.payback_id) || {}).status) === 2) {
|
||||
ElMessage.warning("所属回款计划已完成(回款已收齐),不允许删除回款记录");
|
||||
|
||||
@@ -372,11 +372,19 @@ export function projectOverdue(row) {
|
||||
return { overdue, days: overdue ? days : 0, text: overdue ? overdueDuration(dateStr).text : "" };
|
||||
}
|
||||
|
||||
/** 合同逾期信息:{ overdue, days, text }(text 为"逾期 x 年 y 天"日历换算) */
|
||||
/** 合同逾期信息:{ overdue, days, text }(text 为"逾期 x 年 y 天"日历换算)
|
||||
* 两个口径取更早(更严重)者:
|
||||
* - 合同结束日期(expire_date)已过;
|
||||
* - 回款计划逾期(payback_overdue_date:未收齐且已过计划回款日期的最早日期,后端随列表/详情返回)。
|
||||
* 已完成(2) / 已作废(3) 合同不提示。 */
|
||||
export function contractOverdue(row) {
|
||||
const days = overdueDays(row?.expire_date);
|
||||
const expireDays = overdueDays(row?.expire_date);
|
||||
const paybackDays = overdueDays(row?.payback_overdue_date);
|
||||
const days = Math.max(expireDays || 0, paybackDays || 0);
|
||||
const overdue = days > 0 && ![2, 3].includes(Number(row?.status));
|
||||
return { overdue, days: overdue ? days : 0, text: overdue ? overdueDuration(row?.expire_date).text : "" };
|
||||
// 文案按「更早(逾期更久)」的日期换算
|
||||
const baseDate = paybackDays > expireDays ? row?.payback_overdue_date : row?.expire_date;
|
||||
return { overdue, days: overdue ? days : 0, text: overdue ? overdueDuration(baseDate).text : "" };
|
||||
}
|
||||
|
||||
/** 回款明细逾期信息:{ overdue, days, text }
|
||||
|
||||
@@ -15,25 +15,19 @@
|
||||
</div>
|
||||
|
||||
|
||||
<el-button type="primary" link :icon="Edit" @click="emit('edit', detail)">编辑</el-button>
|
||||
<el-tooltip
|
||||
:disabled="Number(detail.status) !== 2"
|
||||
content="该回款计划已完成(回款已收齐),不允许删除"
|
||||
placement="top"
|
||||
>
|
||||
<span>
|
||||
<el-button
|
||||
type="danger"
|
||||
link
|
||||
:icon="Delete"
|
||||
:disabled="Number(detail.status) === 2"
|
||||
@click="handleDelete"
|
||||
style="margin-left: 0px"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
<!-- 已完成的回款计划(回款已收齐)不可编辑、不可删除,不显示对应按钮 -->
|
||||
<template v-if="Number(detail.status) !== 2">
|
||||
<el-button type="primary" link :icon="Edit" @click="emit('edit', detail)">编辑</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
link
|
||||
:icon="Delete"
|
||||
@click="handleDelete"
|
||||
style="margin-left: 0px"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<el-descriptions :column="2" border class="desc-block">
|
||||
@@ -98,7 +92,23 @@
|
||||
<span class="section-tip">
|
||||
共 {{ records.length }} 笔,累计 ¥{{ formatMoney(receivedSum) }}
|
||||
</span>
|
||||
<el-button type="primary" size="small" :icon="Plus" @click="openRecord()">新建回款记录</el-button>
|
||||
<!-- 计划已完成(回款已收齐)时不可再新建回款记录 -->
|
||||
<el-tooltip
|
||||
:disabled="Number(detail.status) !== 2"
|
||||
content="该回款计划已完成(回款已收齐),不可再新建回款记录"
|
||||
placement="top"
|
||||
>
|
||||
<span>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:icon="Plus"
|
||||
:disabled="Number(detail.status) === 2"
|
||||
@click="openRecord()"
|
||||
>新建回款记录</el-button
|
||||
>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -133,23 +143,15 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip
|
||||
:disabled="Number(detail.status) !== 2"
|
||||
content="该回款计划已完成(回款已收齐),不允许删除回款记录"
|
||||
placement="top"
|
||||
<!-- 所属回款计划已完成(回款已收齐)的记录不可删除,不显示按钮 -->
|
||||
<el-button
|
||||
v-if="Number(detail.status) !== 2"
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="handleDeleteRecord(row)"
|
||||
>删除</el-button
|
||||
>
|
||||
<span>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="Number(detail.status) === 2"
|
||||
@click="handleDeleteRecord(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
@@ -389,6 +391,11 @@ function todayStr() {
|
||||
* 进度节点在弹窗内选择(按进度选择性付款),不指定时按整单登记。
|
||||
*/
|
||||
function openRecord() {
|
||||
// 兜底拦截:计划已完成(回款已收齐)时不允许再登记
|
||||
if (Number(detail.value.status) === 2) {
|
||||
ElMessage.warning("该回款计划已完成(回款已收齐),不可再新建回款记录");
|
||||
return;
|
||||
}
|
||||
Object.assign(recordForm, {
|
||||
amount: 0,
|
||||
received_date: todayStr(),
|
||||
|
||||
@@ -135,24 +135,11 @@
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.__isPlan">
|
||||
<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-tooltip
|
||||
:disabled="Number(row.status) !== 2"
|
||||
content="该回款计划已完成(回款已收齐),不允许删除"
|
||||
placement="top"
|
||||
>
|
||||
<span>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="Number(row.status) === 2"
|
||||
@click="handleRowDelete(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
<!-- 已完成的回款计划(回款已收齐)不可编辑、不可删除,不显示对应按钮 -->
|
||||
<template v-if="Number(row.status) !== 2">
|
||||
<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>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-link
|
||||
@@ -174,23 +161,15 @@
|
||||
>
|
||||
补回执
|
||||
</el-button>
|
||||
<el-tooltip
|
||||
:disabled="Number(row.__planStatus) !== 2"
|
||||
content="所属回款计划已完成(回款已收齐),不允许删除回款记录"
|
||||
placement="top"
|
||||
<!-- 所属回款计划已完成(回款已收齐)的记录不可删除,不显示按钮 -->
|
||||
<el-button
|
||||
v-if="Number(row.__planStatus) !== 2"
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="handleDeleteRecord(row)"
|
||||
>删除</el-button
|
||||
>
|
||||
<span>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="Number(row.__planStatus) === 2"
|
||||
@click="handleDeleteRecord(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -299,7 +278,7 @@ async function fetchList() {
|
||||
children: (p.children || []).map((r) => ({
|
||||
...r,
|
||||
__isPlan: false,
|
||||
__planStatus: p.status, // 带上所属计划状态,用于禁止删除已完成计划的回款记录
|
||||
__planStatus: p.status, // 所属计划状态:已完成时记录行不显示删除按钮
|
||||
__rowKey: `rec_${r.id}`,
|
||||
})),
|
||||
}));
|
||||
@@ -382,7 +361,7 @@ async function handleRowDelete(row) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除子行的回款记录(同步回写计划已回款金额;所属计划已完成时禁止) */
|
||||
/** 删除子行的回款记录(后端同步重算计划已回款金额与状态;所属计划已完成时禁止) */
|
||||
async function handleDeleteRecord(row) {
|
||||
if (Number(row.__planStatus) === 2) {
|
||||
ElMessage.warning("所属回款计划已完成(回款已收齐),不允许删除回款记录");
|
||||
|
||||
@@ -85,50 +85,84 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 列表视图 -->
|
||||
<!-- 列表视图:子文件夹(在前)与同级文件混排展示 -->
|
||||
<el-table
|
||||
v-if="viewMode === 'list'"
|
||||
:data="list"
|
||||
:data="listRows"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
border
|
||||
size="small"
|
||||
>
|
||||
<el-table-column label="文件名" min-width="150" show-overflow-tooltip>
|
||||
<el-table-column label="名称" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<a :href="row.file_url" target="_blank" class="file-link">
|
||||
<!-- 子文件夹:点击进入(与左侧树点击一致) -->
|
||||
<span v-if="row.__isFolder" class="folder-link" @click="enterFolder(row)">
|
||||
<el-icon class="folder-link-icon"><Folder /></el-icon>
|
||||
<span class="folder-link-name">{{ row.name }}</span>
|
||||
<span v-if="row.doc_count > 0" class="tree-node-count">{{ row.doc_count }}</span>
|
||||
</span>
|
||||
<a v-else :href="row.file_url" target="_blank" class="file-link">
|
||||
{{ row.file_name || row.title || "-" }}
|
||||
</a>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="64" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" effect="plain">{{ docTypeText(row.doc_type) }}</el-tag>
|
||||
<el-tag v-if="row.__isFolder" size="small" type="warning" effect="plain">文件夹</el-tag>
|
||||
<el-tag v-else size="small" effect="plain">{{ docTypeText(row.doc_type) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="所在文件夹" min-width="88" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.folder_name || projectName || "-" }}</template>
|
||||
<template #default="{ row }">
|
||||
{{ row.__isFolder ? currentFolderLabel : row.folder_name || projectName || "-" }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="大小" min-width="72" align="right">
|
||||
<template #default="{ row }">{{ formatFileSize(row.size) }}</template>
|
||||
<template #default="{ row }">{{ row.__isFolder ? "-" : formatFileSize(row.size) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="creator_name" label="上传人" min-width="70" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.__isFolder ? "-" : row.creator_name || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="creator_name" label="上传人" min-width="70" show-overflow-tooltip />
|
||||
<el-table-column label="上传时间" min-width="132">
|
||||
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
|
||||
<template #default="{ row }">{{ row.__isFolder ? "-" : formatDateTime(row.create_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="64" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
<el-button
|
||||
v-if="row.__isFolder"
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="enterFolder(row)"
|
||||
>打开</el-button
|
||||
>
|
||||
<el-button v-else link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="暂无项目文档" :image-size="60" /></template>
|
||||
<template #empty>
|
||||
<el-empty description="该文件夹暂无内容" :image-size="60" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<!-- 图标视图:文件卡片(点击文件图标/名称打开,删除在卡片底部) -->
|
||||
<!-- 图标视图:子文件夹卡片 + 文件卡片混排(文件夹点击进入;文件点击打开,删除在卡片底部) -->
|
||||
<div v-else class="doc-grid" v-loading="loading">
|
||||
<div
|
||||
v-for="f in subFolders"
|
||||
:key="`folder_${f.id}`"
|
||||
class="doc-card folder-card"
|
||||
:title="`${f.name}(文件夹)`"
|
||||
@click="enterFolder(f)"
|
||||
>
|
||||
<span class="doc-card-icon folder-card-icon">
|
||||
<el-icon><FolderOpened /></el-icon>
|
||||
</span>
|
||||
<span class="doc-card-name">{{ f.name }}</span>
|
||||
<span class="doc-card-meta">{{ f.doc_count > 0 ? `${f.doc_count} 个文件` : "文件夹" }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="row in list"
|
||||
:key="row.id"
|
||||
:key="`doc_${row.id}`"
|
||||
class="doc-card"
|
||||
:title="`${row.file_name || row.title || '-'}(${formatFileSize(row.size)})`"
|
||||
>
|
||||
@@ -144,7 +178,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<el-empty
|
||||
v-if="!list.length && !loading"
|
||||
v-if="!list.length && !subFolders.length && !loading"
|
||||
description="暂无项目文档"
|
||||
:image-size="60"
|
||||
class="grid-empty"
|
||||
@@ -282,6 +316,33 @@ function findFolder(nodes, id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前选中文件夹下的子文件夹(与同级文件一起展示)。
|
||||
* 例:点击二级文件夹后,里面的子文件夹与文件同级,两者一并列出(列表/图标视图均如此)。
|
||||
*/
|
||||
const subFolders = computed(() => {
|
||||
const node = findFolder(folderTree.value, selectedFolder.value);
|
||||
return node?.children || [];
|
||||
});
|
||||
|
||||
/** 列表视图数据:子文件夹在前(点击进入),其后为当前页文件 */
|
||||
const listRows = computed(() => [
|
||||
...subFolders.value.map((f) => ({
|
||||
...f,
|
||||
__isFolder: true,
|
||||
creator_name: "-", // 文件夹行占位,避免各列显示异常
|
||||
})),
|
||||
...list.value.map((r) => ({ ...r, __isFolder: false })),
|
||||
]);
|
||||
|
||||
/** 进入(浏览)子文件夹:与左侧树点击行为一致 */
|
||||
function enterFolder(folder) {
|
||||
if (!folder?.id || folder.id === selectedFolder.value) return;
|
||||
selectedFolder.value = folder.id;
|
||||
pagination.page = 1;
|
||||
reload();
|
||||
}
|
||||
|
||||
/** 左侧树节点点击:切换筛选文件夹(回到第一页) */
|
||||
function handleNodeClick(data) {
|
||||
if (!data.id || data.id === selectedFolder.value) return;
|
||||
@@ -554,18 +615,19 @@ defineExpose({ reload });
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.tree-node-count {
|
||||
flex-shrink: 0;
|
||||
min-width: 16px;
|
||||
padding: 0 5px;
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color);
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
/* 文件夹数量角标:左侧树与右侧列表(子文件夹行)共用 */
|
||||
.tree-node-count {
|
||||
flex-shrink: 0;
|
||||
min-width: 16px;
|
||||
padding: 0 5px;
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color);
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 右侧:文档列表 */
|
||||
@@ -630,6 +692,24 @@ defineExpose({ reload });
|
||||
}
|
||||
}
|
||||
|
||||
/* 列表视图:子文件夹名称(点击进入) */
|
||||
.folder-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.folder-link-icon {
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
}
|
||||
|
||||
/* 分页:列表 / 图标视图共用 */
|
||||
.docs-pagination {
|
||||
display: flex;
|
||||
@@ -707,6 +787,20 @@ defineExpose({ reload });
|
||||
}
|
||||
}
|
||||
|
||||
/* 文件夹卡片:点击进入该文件夹 */
|
||||
.folder-card {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover .doc-card-name {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.folder-card-icon {
|
||||
display: inline-flex;
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
}
|
||||
|
||||
.grid-empty {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
@@ -193,6 +193,17 @@ func (c *BackendCrmContractController) List() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 回款逾期日期(取最早,未收齐且已过计划回款日期):合同「逾期」标签与结束日期口径一并参考
|
||||
if len(list) > 0 {
|
||||
contractIDs := make([]uint64, 0, len(list))
|
||||
for i := range list {
|
||||
contractIDs = append(contractIDs, list[i].ID)
|
||||
}
|
||||
overdues := paybackOverdueDateByContracts(tenantID, contractIDs)
|
||||
for i := range list {
|
||||
list[i].PaybackOverdueDate = overdues[list[i].ID]
|
||||
}
|
||||
}
|
||||
items := make([]contractResp, 0, len(list))
|
||||
for i := range list {
|
||||
items = append(items, buildContractResp(&list[i]))
|
||||
@@ -303,6 +314,10 @@ func (c *BackendCrmContractController) Detail() {
|
||||
pipelineErr(&c.Controller, 403, 403, "无权查看该合同数据")
|
||||
return
|
||||
}
|
||||
// 回款逾期日期(取最早,未收齐且已过计划回款日期):合同「逾期」标签与结束日期口径一并参考
|
||||
if od := paybackOverdueDateByContracts(pipelineTenantID(claims), []uint64{row.ID}); od[row.ID] != "" {
|
||||
row.PaybackOverdueDate = od[row.ID]
|
||||
}
|
||||
pipelineOk(&c.Controller, buildContractResp(&row))
|
||||
}
|
||||
|
||||
|
||||
@@ -229,6 +229,11 @@ func (c *BackendCrmPaybackController) Create() {
|
||||
return
|
||||
}
|
||||
tenantID := pipelineTenantID(claims)
|
||||
// 回款已全部收齐(进度 100%)的合同不允许再新建回款计划(与回款进度汇总口径一致)
|
||||
if paybackContractPaidOff(tenantID, p.ContractID) {
|
||||
pipelineErr(&c.Controller, 400, 400, "回款已全部完成(进度 100%),不可再新建回款计划")
|
||||
return
|
||||
}
|
||||
|
||||
itemsJSON, totalAmount, err := normalizePaybackItems(p.Items)
|
||||
if err != nil {
|
||||
@@ -308,6 +313,11 @@ func (c *BackendCrmPaybackController) Update() {
|
||||
pipelineErr(&c.Controller, 403, 403, "无权操作该回款计划数据")
|
||||
return
|
||||
}
|
||||
// 已完成(回款已收齐)的回款计划不允许编辑,保证已收齐数据稳定
|
||||
if row.Status == 2 {
|
||||
pipelineErr(&c.Controller, 400, 400, "该回款计划已完成(回款已收齐),不允许编辑")
|
||||
return
|
||||
}
|
||||
|
||||
if p.ContractID > 0 {
|
||||
row.ContractID = &p.ContractID
|
||||
@@ -393,7 +403,7 @@ func (c *BackendCrmPaybackController) Delete() {
|
||||
pipelineErr(&c.Controller, 403, 403, "只有创建人、租户管理员或平台管理员可以删除该回款计划")
|
||||
return
|
||||
}
|
||||
// 已完成(回款已收齐)的回款计划不允许删除
|
||||
// 已完成(回款已收齐)的回款计划不允许删除,避免破坏已收齐数据
|
||||
if row.Status == 2 {
|
||||
pipelineErr(&c.Controller, 400, 400, "该回款计划已完成(回款已收齐),不允许删除")
|
||||
return
|
||||
@@ -801,6 +811,11 @@ func (c *BackendCrmPaybackController) CreateRecord() {
|
||||
pipelineErr(&c.Controller, 404, 404, "回款计划不存在")
|
||||
return
|
||||
}
|
||||
// 计划已完成(回款已收齐)时不允许再新建回款记录
|
||||
if plan.Status == 2 {
|
||||
pipelineErr(&c.Controller, 400, 400, "该回款计划已完成(回款已收齐),不可再新建回款记录")
|
||||
return
|
||||
}
|
||||
payMethod := p.PayMethod
|
||||
if payMethod <= 0 {
|
||||
payMethod = plan.PayMethod
|
||||
@@ -950,6 +965,115 @@ func (c *BackendCrmPaybackController) UpdateRecord() {
|
||||
})
|
||||
}
|
||||
|
||||
// paybackOverdueDateByContracts 批量计算各合同的「回款逾期日期」(yyyy-MM-dd,取最早):
|
||||
// 合同下全部未收齐回款计划中,存在「未收齐且已过计划回款日期」的明细时取最早日期;
|
||||
// 与回款逾期联动(合同置「异常」)同一判定口径,供合同列表 / 详情的「逾期」标签一并参考。
|
||||
func paybackOverdueDateByContracts(tenantID string, contractIDs []uint64) map[uint64]string {
|
||||
out := make(map[uint64]string)
|
||||
if models.Orm == nil || tenantID == "" || len(contractIDs) == 0 {
|
||||
return out
|
||||
}
|
||||
today := time.Now().Format("2006-01-02")
|
||||
var plans []models.TenantCrmPayback
|
||||
if _, err := models.Orm.QueryTable(new(models.TenantCrmPayback)).
|
||||
Filter("tenant_id", tenantID).
|
||||
Filter("contract_id__in", contractIDs).
|
||||
Filter("delete_time__isnull", true).
|
||||
Filter("status__ne", 2). // 已收齐(已完成)的计划跳过
|
||||
All(&plans, "id", "contract_id", "items"); err != nil {
|
||||
return out
|
||||
}
|
||||
if len(plans) == 0 {
|
||||
return out
|
||||
}
|
||||
// 回款记录按「计划 + 明细序号」汇总(item_seq=0 视为整单登记,不计入任何明细)
|
||||
planIDs := make([]uint64, 0, len(plans))
|
||||
for i := range plans {
|
||||
planIDs = append(planIDs, plans[i].ID)
|
||||
}
|
||||
var recs []models.TenantCrmPaybackRecord
|
||||
_, _ = models.Orm.QueryTable(new(models.TenantCrmPaybackRecord)).
|
||||
Filter("payback_id__in", planIDs).
|
||||
Filter("delete_time__isnull", true).
|
||||
All(&recs, "payback_id", "item_seq", "amount")
|
||||
receivedByPlan := make(map[uint64]map[int]float64, len(plans))
|
||||
for _, r := range recs {
|
||||
if r.ItemSeq <= 0 {
|
||||
continue
|
||||
}
|
||||
m := receivedByPlan[r.PaybackID]
|
||||
if m == nil {
|
||||
m = make(map[int]float64)
|
||||
receivedByPlan[r.PaybackID] = m
|
||||
}
|
||||
m[r.ItemSeq] += r.Amount
|
||||
}
|
||||
for i := range plans {
|
||||
plan := plans[i]
|
||||
if plan.ContractID == nil || *plan.ContractID == 0 {
|
||||
continue
|
||||
}
|
||||
date := paybackPlanEarliestOverdueDate(plan, receivedByPlan[plan.ID], today)
|
||||
if date == "" {
|
||||
continue
|
||||
}
|
||||
cid := *plan.ContractID
|
||||
if cur, ok := out[cid]; !ok || date < cur {
|
||||
out[cid] = date
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// paybackPlanEarliestOverdueDate 计划明细中最早的「未收齐且已过计划回款日期」的日期;无逾期返回空串。
|
||||
func paybackPlanEarliestOverdueDate(plan models.TenantCrmPayback, receivedBySeq map[int]float64, today string) string {
|
||||
items := parsePaybackItems(plan.Items)
|
||||
if len(items) == 0 {
|
||||
return ""
|
||||
}
|
||||
earliest := ""
|
||||
for _, it := range items {
|
||||
received := receivedBySeq[it.Seq]
|
||||
if it.ReceivedAmount > received {
|
||||
received = it.ReceivedAmount
|
||||
}
|
||||
if it.Amount > 0 && received >= it.Amount {
|
||||
continue // 已收齐
|
||||
}
|
||||
planDate := strings.TrimSpace(it.PlanDate)
|
||||
if len(planDate) > 10 {
|
||||
planDate = planDate[:10]
|
||||
}
|
||||
if planDate == "" || planDate >= today {
|
||||
continue
|
||||
}
|
||||
if earliest == "" || planDate < earliest {
|
||||
earliest = planDate
|
||||
}
|
||||
}
|
||||
return earliest
|
||||
}
|
||||
|
||||
// paybackContractPaidOff 判断某合同的回款是否已全部收齐(进度 100%):
|
||||
// 汇总该合同全部有效回款计划的计划总额与已回款金额,口径与回款进度列表的汇总一致。
|
||||
// 已收齐时不允许再新建回款计划(避免收齐后继续追加,保证对账数据稳定)。
|
||||
func paybackContractPaidOff(tenantID string, contractID uint64) bool {
|
||||
if contractID == 0 {
|
||||
return false
|
||||
}
|
||||
var plans []models.TenantCrmPayback
|
||||
_, _ = models.Orm.QueryTable(new(models.TenantCrmPayback)).
|
||||
Filter("tenant_id", tenantID).
|
||||
Filter("contract_id", contractID).
|
||||
Filter("delete_time__isnull", true).All(&plans)
|
||||
var planned, received float64
|
||||
for _, p := range plans {
|
||||
planned = round2(planned + p.TotalAmount)
|
||||
received = round2(received + p.ReceivedAmount)
|
||||
}
|
||||
return planned > 0 && received+0.005 >= planned
|
||||
}
|
||||
|
||||
// refreshPlanReceived 按回款记录重算计划的已回款金额,并同步计划状态(1进行中 / 2已完成)。
|
||||
func refreshPlanReceived(tenantID string, paybackID uint64) {
|
||||
if paybackID == 0 {
|
||||
|
||||
@@ -183,6 +183,27 @@ func attachProjectContractPeriod(tenantID string, list []models.TenantCrmProject
|
||||
}
|
||||
}
|
||||
|
||||
// crmProjectNameDuplicated 项目重名校验:同租户 + 同名 + 同一客户(服务主体)才算重复。
|
||||
// 不同客户的同名项目可正常创建(如两家公司都有「官网建设」项目);
|
||||
// 未关联客户的项目按「客户为空」分组校验(与同样未关联客户的同名项目视为重复)。
|
||||
func crmProjectNameDuplicated(tenantID, name string, customerID *uint64) bool {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || strings.TrimSpace(tenantID) == "" || models.Orm == nil {
|
||||
return false
|
||||
}
|
||||
cond := orm.NewCondition().
|
||||
And("tenant_id", tenantID).
|
||||
And("delete_time__isnull", true).
|
||||
And("project_name", name)
|
||||
if customerID != nil && *customerID > 0 {
|
||||
cond = cond.And("customer_id", *customerID)
|
||||
} else {
|
||||
cond = cond.AndCond(orm.NewCondition().Or("customer_id__isnull", true).Or("customer_id", 0))
|
||||
}
|
||||
cnt, err := models.Orm.QueryTable(new(models.TenantCrmProject)).SetCond(cond).Count()
|
||||
return err == nil && cnt > 0
|
||||
}
|
||||
|
||||
// List GET /backend/crm/project/list
|
||||
func (c *BackendCrmProjectController) List() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
@@ -229,13 +250,6 @@ func (c *BackendCrmProjectController) List() {
|
||||
projectQuery := func(cond *orm.Condition) orm.QuerySeter {
|
||||
return models.Orm.QueryTable(new(models.TenantCrmProject)).SetCond(cond)
|
||||
}
|
||||
// 未完成组:除「已完成」外的全部状态(含历史数据中的空状态),沉底排列时取列表前段
|
||||
unfinishedCond := func() *orm.Condition {
|
||||
return buildCond().AndCond(orm.NewCondition().
|
||||
Or("status__ne", crmProjectStatusDone).
|
||||
Or("status__isnull", true))
|
||||
}
|
||||
|
||||
total, _ := projectQuery(buildCond()).Count()
|
||||
var list []models.TenantCrmProject
|
||||
if total > 0 {
|
||||
@@ -247,32 +261,47 @@ func (c *BackendCrmProjectController) List() {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 列表排列:未完成项目在前、「已完成」项目沉底,两组内部仍按新→旧。
|
||||
// 先按两组数量把当前页窗口换算成各组区间,再分别取数按序拼接,保证翻页后整体顺序一致。
|
||||
unfinished, _ := projectQuery(unfinishedCond()).Count()
|
||||
unfinishedTotal := int(unfinished)
|
||||
finishedTotal := int(total) - unfinishedTotal
|
||||
// 列表排列:按状态分组展示 —— 未开始(1) → 异常(5) → 进行中(2) → 已暂停(4) → 已完成(3),
|
||||
// 其余状态(NULL / 历史异常值)沉底;组内仍按新→旧。
|
||||
// 先统计各组数量,把当前页窗口换算成各组区间,再分别取数按序拼接,保证翻页后整体顺序一致。
|
||||
groupConds := []*orm.Condition{
|
||||
orm.NewCondition().And("status", 1), // 未开始
|
||||
orm.NewCondition().And("status", 5), // 异常
|
||||
orm.NewCondition().And("status", 2), // 进行中
|
||||
orm.NewCondition().And("status", 4), // 已暂停
|
||||
orm.NewCondition().And("status", 3), // 已完成
|
||||
orm.NewCondition().AndNot("status__in", []int8{1, 2, 3, 4, 5}).Or("status__isnull", true),
|
||||
}
|
||||
end := offset + pageSize
|
||||
if end > int(total) {
|
||||
end = int(total)
|
||||
}
|
||||
aFrom, aTo := min(offset, unfinishedTotal), min(end, unfinishedTotal)
|
||||
bFrom := min(max(offset-unfinishedTotal, 0), finishedTotal)
|
||||
bTo := min(max(end-unfinishedTotal, 0), finishedTotal)
|
||||
if aTo > aFrom {
|
||||
if _, err := projectQuery(unfinishedCond()).OrderBy("-id").Offset(aFrom).Limit(aTo - aFrom).All(&list); err != nil {
|
||||
cursor := 0
|
||||
for _, gc := range groupConds {
|
||||
if cursor >= end {
|
||||
break
|
||||
}
|
||||
// 注意:beego 的 AndCond 会修改接收者,这里用新条件对象包装,避免循环内条件累积
|
||||
groupCond := orm.NewCondition().AndCond(buildCond()).AndCond(gc)
|
||||
cnt, _ := projectQuery(groupCond).Count()
|
||||
groupTotal := int(cnt)
|
||||
if groupTotal == 0 {
|
||||
continue
|
||||
}
|
||||
groupStart := cursor
|
||||
groupEnd := cursor + groupTotal
|
||||
cursor = groupEnd
|
||||
// 当前页与本组区间的交集
|
||||
from, to := max(offset, groupStart), min(end, groupEnd)
|
||||
if to <= from {
|
||||
continue
|
||||
}
|
||||
var part []models.TenantCrmProject
|
||||
if _, err := projectQuery(groupCond).OrderBy("-id").Offset(from - groupStart).Limit(to - from).All(&part); err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "查询失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if bTo > bFrom {
|
||||
var finished []models.TenantCrmProject
|
||||
if _, err := projectQuery(buildCond().And("status", crmProjectStatusDone)).
|
||||
OrderBy("-id").Offset(bFrom).Limit(bTo - bFrom).All(&finished); err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "查询失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
list = append(list, finished...)
|
||||
list = append(list, part...)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -394,9 +423,10 @@ func (c *BackendCrmProjectController) Create() {
|
||||
CreateTime: now,
|
||||
UpdateTime: now,
|
||||
}
|
||||
// 唯一性校验:同租户内不允许重复创建同名项目(重复数据请联系管理员转移跟进)
|
||||
if crmNameDuplicated(new(models.TenantCrmProject), tenantID, "project_name", proj.ProjectName) {
|
||||
pipelineErr(&c.Controller, 409, 409, "该项目名称已存在(可能由其他同事创建),如需继续跟进请联系管理员转移")
|
||||
// 唯一性校验:同一客户(服务主体)下的同名项目不允许重复创建(重复数据请联系管理员转移跟进);
|
||||
// 客户不同则允许同名创建(不同主体可各自立项,如两家客户都做「官网建设」)
|
||||
if crmProjectNameDuplicated(tenantID, proj.ProjectName, proj.CustomerID) {
|
||||
pipelineErr(&c.Controller, 409, 409, "该客户下已存在同名项目(可能由其他同事创建),如需继续跟进请联系管理员转移")
|
||||
return
|
||||
}
|
||||
id, err := models.Orm.Insert(&proj)
|
||||
|
||||
@@ -40,6 +40,11 @@ type TenantCrmContract struct {
|
||||
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"`
|
||||
|
||||
// 回款逾期日期(非表字段,列表 / 详情查询时计算填充):
|
||||
// 合同下未收齐回款计划里最早的一个「已过计划回款日期」的日期(yyyy-MM-dd);
|
||||
// 供「逾期」标签与合同结束日期口径一并参考,无回款逾期时为空。
|
||||
PaybackOverdueDate string `orm:"-" json:"payback_overdue_date"`
|
||||
}
|
||||
|
||||
func (m *TenantCrmContract) TableName() string {
|
||||
|
||||
Reference in New Issue
Block a user