From 4ab5effce6f1be654cd9652c83f55d88d8ded2b3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=9D=8E=E5=BF=97=E5=BC=BA?= <357099073@qq.com>
Date: Mon, 14 Sep 2026 17:30:12 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=A1=B9=E7=9B=AE=E7=AE=A1?=
=?UTF-8?q?=E7=90=86?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../apps/crm/contract/components/create.vue | 10 +-
.../contract/components/detail_payback.vue | 60 +++++---
backend/src/views/apps/crm/dict.js | 14 +-
.../apps/crm/payback/components/detail.vue | 79 +++++-----
backend/src/views/apps/crm/payback/index.vue | 51 ++-----
.../crm/project/components/detail_docs.vue | 144 +++++++++++++++---
go/controllers/backend_crm_contract.go | 15 ++
go/controllers/backend_crm_payback.go | 126 ++++++++++++++-
go/controllers/backend_crm_project.go | 88 +++++++----
go/models/tenant_crm_contract.go | 5 +
10 files changed, 434 insertions(+), 158 deletions(-)
diff --git a/backend/src/views/apps/crm/contract/components/create.vue b/backend/src/views/apps/crm/contract/components/create.vue
index 6897f65..f100bb5 100644
--- a/backend/src/views/apps/crm/contract/components/create.vue
+++ b/backend/src/views/apps/crm/contract/components/create.vue
@@ -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四方 */
diff --git a/backend/src/views/apps/crm/contract/components/detail_payback.vue b/backend/src/views/apps/crm/contract/components/detail_payback.vue
index 35baca9..4c34820 100644
--- a/backend/src/views/apps/crm/contract/components/detail_payback.vue
+++ b/backend/src/views/apps/crm/contract/components/detail_payback.vue
@@ -2,6 +2,7 @@
回款计划
+
@@ -88,9 +89,18 @@
已回款:¥{{ formatMoney(progressSummary.total_received) }}
待回款:¥{{ formatMoney((progressSummary.total_planned || 0) - (progressSummary.total_received || 0)) }}
-
+
+
-
+
新建回款
@@ -115,15 +125,15 @@
{{ paybackMethodText(row.pay_method) }}
-
+
+ {{ row.remark || "-" }}
+
+
查看回执
-
-
- {{ row.remark || "-" }}
-
补回执
-
+ 删除
-
-
- 删除
-
-
-
@@ -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("所属回款计划已完成(回款已收齐),不允许删除回款记录");
diff --git a/backend/src/views/apps/crm/dict.js b/backend/src/views/apps/crm/dict.js
index ea115e6..5575afd 100644
--- a/backend/src/views/apps/crm/dict.js
+++ b/backend/src/views/apps/crm/dict.js
@@ -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 }
diff --git a/backend/src/views/apps/crm/payback/components/detail.vue b/backend/src/views/apps/crm/payback/components/detail.vue
index b1b894d..9f68b88 100644
--- a/backend/src/views/apps/crm/payback/components/detail.vue
+++ b/backend/src/views/apps/crm/payback/components/detail.vue
@@ -15,25 +15,19 @@
- 编辑
-
-
-
- 删除
-
-
-
+
+
+ 编辑
+
+ 删除
+
+
@@ -98,7 +92,23 @@
共 {{ records.length }} 笔,累计 ¥{{ formatMoney(receivedSum) }}
- 新建回款记录
+
+
+
+ 新建回款记录
+
+
@@ -133,23 +143,15 @@
-
+ 删除
-
-
- 删除
-
-
-
@@ -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(),
diff --git a/backend/src/views/apps/crm/payback/index.vue b/backend/src/views/apps/crm/payback/index.vue
index b4b9de9..390dc85 100644
--- a/backend/src/views/apps/crm/payback/index.vue
+++ b/backend/src/views/apps/crm/payback/index.vue
@@ -135,24 +135,11 @@
详情
- 编辑
-
-
-
- 删除
-
-
-
+
+
+ 编辑
+ 删除
+
补回执
-
+ 删除
-
-
- 删除
-
-
-
@@ -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("所属回款计划已完成(回款已收齐),不允许删除回款记录");
diff --git a/backend/src/views/apps/crm/project/components/detail_docs.vue b/backend/src/views/apps/crm/project/components/detail_docs.vue
index 051f4fe..db2c9aa 100644
--- a/backend/src/views/apps/crm/project/components/detail_docs.vue
+++ b/backend/src/views/apps/crm/project/components/detail_docs.vue
@@ -85,50 +85,84 @@
-
+
-
+
-
+
+
+
+ {{ row.name }}
+ {{ row.doc_count }}
+
+
{{ row.file_name || row.title || "-" }}
- {{ docTypeText(row.doc_type) }}
+ 文件夹
+ {{ docTypeText(row.doc_type) }}
- {{ row.folder_name || projectName || "-" }}
+
+ {{ row.__isFolder ? currentFolderLabel : row.folder_name || projectName || "-" }}
+
- {{ formatFileSize(row.size) }}
+ {{ row.__isFolder ? "-" : formatFileSize(row.size) }}
+
+
+ {{ row.__isFolder ? "-" : row.creator_name || "-" }}
-
- {{ formatDateTime(row.create_time) }}
+ {{ row.__isFolder ? "-" : formatDateTime(row.create_time) }}
- 删除
+ 打开
+ 删除
-
+
+
+
-
+
+
+
+
+
+ {{ f.name }}
+ {{ f.doc_count > 0 ? `${f.doc_count} 个文件` : "文件夹" }}
+
@@ -144,7 +178,7 @@
{
+ 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;
}
diff --git a/go/controllers/backend_crm_contract.go b/go/controllers/backend_crm_contract.go
index 9a11dba..0422f27 100644
--- a/go/controllers/backend_crm_contract.go
+++ b/go/controllers/backend_crm_contract.go
@@ -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))
}
diff --git a/go/controllers/backend_crm_payback.go b/go/controllers/backend_crm_payback.go
index 5877d4a..250c4eb 100644
--- a/go/controllers/backend_crm_payback.go
+++ b/go/controllers/backend_crm_payback.go
@@ -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 {
diff --git a/go/controllers/backend_crm_project.go b/go/controllers/backend_crm_project.go
index bd8e81f..2236b14 100644
--- a/go/controllers/backend_crm_project.go
+++ b/go/controllers/backend_crm_project.go
@@ -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)
diff --git a/go/models/tenant_crm_contract.go b/go/models/tenant_crm_contract.go
index 7b3379d..40bbfa2 100644
--- a/go/models/tenant_crm_contract.go
+++ b/go/models/tenant_crm_contract.go
@@ -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 {