From 9c07af2b592ddda831bcbc23d8917cbba5240fda Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=9D=8E=E5=BF=97=E5=BC=BA?= <357099073@qq.com>
Date: Fri, 11 Sep 2026 11:11:43 +0800
Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=95=86=E6=9C=BA=E7=BA=BF?=
=?UTF-8?q?=E7=B4=A2=E7=AE=A1=E7=90=86=E4=BF=AE=E5=A4=8D=E8=8B=A5=E5=B9=B2?=
=?UTF-8?q?bug?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/package-lock.json | 7 +
backend/package.json | 1 +
backend/src/api/crmPipeline.js | 139 +++++
.../apps/crm/bidding/components/index.vue | 0
.../apps/crm/business/components/convert.vue | 112 ++++
.../apps/crm/business/components/edit.vue | 276 +++++++++
backend/src/views/apps/crm/business/index.vue | 258 ++++++++
.../apps/crm/clue/components/convert.vue | 280 +++++++++
.../views/apps/crm/clue/components/detail.vue | 554 ++++++++++++++++++
.../views/apps/crm/clue/components/edit.vue | 264 +++++++++
backend/src/views/apps/crm/clue/index.vue | 238 ++++++++
.../apps/crm/components/RichContentEditor.vue | 199 +++++++
backend/src/views/apps/crm/contact/index.vue | 197 +++++--
backend/src/views/apps/crm/contract/index.vue | 0
backend/src/views/apps/crm/dict.js | 133 +++++
.../views/apps/crm/follow/components/edit.vue | 208 +++++++
backend/src/views/apps/crm/follow/index.vue | 175 ++++++
backend/src/views/apps/crm/payback/index.vue | 0
.../apps/crm/project/components/edit.vue | 239 ++++++++
backend/src/views/apps/crm/project/index.vue | 234 ++++++++
.../src/views/apps/crm/styles/crm-page.less | 60 ++
go/controllers/backend_crm_business.go | 438 ++++++++++++++
go/controllers/backend_crm_clue.go | 476 +++++++++++++++
go/controllers/backend_crm_pipeline_common.go | 247 ++++++++
go/controllers/backend_crm_project.go | 282 +++++++++
go/controllers/backend_crm_support.go | 540 +++++++++++++++++
go/models/erp_company_contact.go | 5 +-
go/models/init.go | 7 +
go/models/tenant_crm_attach.go | 25 +
go/models/tenant_crm_business.go | 41 ++
go/models/tenant_crm_clue.go | 37 ++
go/models/tenant_crm_entity_contact.go | 27 +
go/models/tenant_crm_follow.go | 26 +
go/models/tenant_crm_operate_log.go | 21 +
go/models/tenant_crm_project.go | 34 ++
go/routers/backend/backend.go | 37 ++
sql/alter_company_contact_unify.sql | 198 +++++++
...lter_crm_pipeline_add_contact_position.sql | 13 +
sql/yz_backend_crm_pipeline.sql | 233 ++++++++
39 files changed, 6219 insertions(+), 42 deletions(-)
create mode 100644 backend/src/api/crmPipeline.js
create mode 100644 backend/src/views/apps/crm/bidding/components/index.vue
create mode 100644 backend/src/views/apps/crm/business/components/convert.vue
create mode 100644 backend/src/views/apps/crm/business/components/edit.vue
create mode 100644 backend/src/views/apps/crm/business/index.vue
create mode 100644 backend/src/views/apps/crm/clue/components/convert.vue
create mode 100644 backend/src/views/apps/crm/clue/components/detail.vue
create mode 100644 backend/src/views/apps/crm/clue/components/edit.vue
create mode 100644 backend/src/views/apps/crm/clue/index.vue
create mode 100644 backend/src/views/apps/crm/components/RichContentEditor.vue
create mode 100644 backend/src/views/apps/crm/contract/index.vue
create mode 100644 backend/src/views/apps/crm/follow/components/edit.vue
create mode 100644 backend/src/views/apps/crm/follow/index.vue
create mode 100644 backend/src/views/apps/crm/payback/index.vue
create mode 100644 backend/src/views/apps/crm/project/components/edit.vue
create mode 100644 backend/src/views/apps/crm/project/index.vue
create mode 100644 backend/src/views/apps/crm/styles/crm-page.less
create mode 100644 go/controllers/backend_crm_business.go
create mode 100644 go/controllers/backend_crm_clue.go
create mode 100644 go/controllers/backend_crm_pipeline_common.go
create mode 100644 go/controllers/backend_crm_project.go
create mode 100644 go/controllers/backend_crm_support.go
create mode 100644 go/models/tenant_crm_attach.go
create mode 100644 go/models/tenant_crm_business.go
create mode 100644 go/models/tenant_crm_clue.go
create mode 100644 go/models/tenant_crm_entity_contact.go
create mode 100644 go/models/tenant_crm_follow.go
create mode 100644 go/models/tenant_crm_operate_log.go
create mode 100644 go/models/tenant_crm_project.go
create mode 100644 sql/alter_company_contact_unify.sql
create mode 100644 sql/alter_crm_pipeline_add_contact_position.sql
create mode 100644 sql/yz_backend_crm_pipeline.sql
diff --git a/backend/package-lock.json b/backend/package-lock.json
index 5a23145..f053a40 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -24,6 +24,7 @@
"os": "^0.1.2",
"pdfjs-dist": "^6.2.108",
"pinia": "^3.0.3",
+ "pinyin-pro": "^3.29.3",
"tesseract.js": "^7.0.0",
"v-viewer": "^3.0.11",
"vue": "^3.5.22",
@@ -6555,6 +6556,12 @@
}
}
},
+ "node_modules/pinyin-pro": {
+ "version": "3.29.3",
+ "resolved": "https://registry.npmmirror.com/pinyin-pro/-/pinyin-pro-3.29.3.tgz",
+ "integrity": "sha512-+UU9bx6vfDw8amOJGHm0TE0rdQl8VPylsDWviQ5OOQ3e+on1xRP4OqDbiDuMT5OISgvfl/Y6ez1BBRaIP80GLQ==",
+ "license": "MIT"
+ },
"node_modules/pkg-types": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-2.3.0.tgz",
diff --git a/backend/package.json b/backend/package.json
index 6ecbd3e..67d047e 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -25,6 +25,7 @@
"os": "^0.1.2",
"pdfjs-dist": "^6.2.108",
"pinia": "^3.0.3",
+ "pinyin-pro": "^3.29.3",
"tesseract.js": "^7.0.0",
"v-viewer": "^3.0.11",
"vue": "^3.5.22",
diff --git a/backend/src/api/crmPipeline.js b/backend/src/api/crmPipeline.js
new file mode 100644
index 0000000..c482bde
--- /dev/null
+++ b/backend/src/api/crmPipeline.js
@@ -0,0 +1,139 @@
+import request from "@/utils/request";
+
+/**
+ * CRM 业务管线接口:线索 → 商机 → 项目,回访贯穿全流程。
+ *
+ * 关联类型 related_type:1=线索 2=商机 3=项目
+ */
+
+/* --------------------------------- 线索 --------------------------------- */
+
+export function getClueList(params) {
+ return request({ url: "/backend/crm/clue/list", method: "get", params });
+}
+
+export function getClueDetail(id) {
+ return request({ url: `/backend/crm/clue/${id}`, method: "get" });
+}
+
+export function createClue(data) {
+ return request({ url: "/backend/crm/clue", method: "post", data });
+}
+
+export function updateClue(id, data) {
+ return request({ url: `/backend/crm/clue/${id}`, method: "put", data });
+}
+
+export function deleteClue(id) {
+ return request({ url: `/backend/crm/clue/${id}`, method: "delete" });
+}
+
+/** 线索转化商机:会把线索客户名称落地为正式客户,并锁定线索 */
+export function convertClueToBusiness(id, data) {
+ return request({ url: `/backend/crm/clue/${id}/convert`, method: "post", data });
+}
+
+/* --------------------------------- 商机 --------------------------------- */
+
+export function getBusinessList(params) {
+ return request({ url: "/backend/crm/business/list", method: "get", params });
+}
+
+export function getBusinessDetail(id) {
+ return request({ url: `/backend/crm/business/${id}`, method: "get" });
+}
+
+export function createBusiness(data) {
+ return request({ url: "/backend/crm/business", method: "post", data });
+}
+
+export function updateBusiness(id, data) {
+ return request({ url: `/backend/crm/business/${id}`, method: "put", data });
+}
+
+export function deleteBusiness(id) {
+ return request({ url: `/backend/crm/business/${id}`, method: "delete" });
+}
+
+/** 商机转化项目 */
+export function convertBusinessToProject(id, data) {
+ return request({ url: `/backend/crm/business/${id}/convert`, method: "post", data });
+}
+
+/* --------------------------------- 项目 --------------------------------- */
+
+export function getProjectList(params) {
+ return request({ url: "/backend/crm/project/list", method: "get", params });
+}
+
+export function getProjectDetail(id) {
+ return request({ url: `/backend/crm/project/${id}`, method: "get" });
+}
+
+export function createProject(data) {
+ return request({ url: "/backend/crm/project", method: "post", data });
+}
+
+export function updateProject(id, data) {
+ return request({ url: `/backend/crm/project/${id}`, method: "put", data });
+}
+
+export function deleteProject(id) {
+ return request({ url: `/backend/crm/project/${id}`, method: "delete" });
+}
+
+/* --------------------------------- 回访 --------------------------------- */
+
+export function getFollowList(params) {
+ return request({ url: "/backend/crm/follow/list", method: "get", params });
+}
+
+export function addFollow(data) {
+ return request({ url: "/backend/crm/follow/add", method: "post", data });
+}
+
+export function updateFollow(data) {
+ return request({ url: "/backend/crm/follow/edit", method: "post", data });
+}
+
+export function deleteFollow(data) {
+ return request({ url: "/backend/crm/follow/delete", method: "post", data });
+}
+
+/* --------------------------------- 附件 --------------------------------- */
+
+export function getAttachList(params) {
+ return request({ url: "/backend/crm/attach/list", method: "get", params });
+}
+
+export function addAttach(data) {
+ return request({ url: "/backend/crm/attach/add", method: "post", data });
+}
+
+export function deleteAttach(data) {
+ return request({ url: "/backend/crm/attach/delete", method: "post", data });
+}
+
+/* ------------------------------ 联系人(实体) ------------------------------ */
+
+export function getEntityContactList(params) {
+ return request({ url: "/backend/crm/entity/contact/list", method: "get", params });
+}
+
+export function addEntityContact(data) {
+ return request({ url: "/backend/crm/entity/contact/add", method: "post", data });
+}
+
+export function updateEntityContact(data) {
+ return request({ url: "/backend/crm/entity/contact/edit", method: "post", data });
+}
+
+export function deleteEntityContact(data) {
+ return request({ url: "/backend/crm/entity/contact/delete", method: "post", data });
+}
+
+/* ------------------------------- 操作日志 ------------------------------- */
+
+export function getCrmOperateLogs(params) {
+ return request({ url: "/backend/crm/oplog/list", method: "get", params });
+}
diff --git a/backend/src/views/apps/crm/bidding/components/index.vue b/backend/src/views/apps/crm/bidding/components/index.vue
new file mode 100644
index 0000000..e69de29
diff --git a/backend/src/views/apps/crm/business/components/convert.vue b/backend/src/views/apps/crm/business/components/convert.vue
new file mode 100644
index 0000000..1013caa
--- /dev/null
+++ b/backend/src/views/apps/crm/business/components/convert.vue
@@ -0,0 +1,112 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ 元
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 确认转化
+
+
+
+
+
diff --git a/backend/src/views/apps/crm/business/components/edit.vue b/backend/src/views/apps/crm/business/components/edit.vue
new file mode 100644
index 0000000..6d102d8
--- /dev/null
+++ b/backend/src/views/apps/crm/business/components/edit.vue
@@ -0,0 +1,276 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 元
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 保存
+
+
+
+
+
diff --git a/backend/src/views/apps/crm/business/index.vue b/backend/src/views/apps/crm/business/index.vue
new file mode 100644
index 0000000..2dcd2c7
--- /dev/null
+++ b/backend/src/views/apps/crm/business/index.vue
@@ -0,0 +1,258 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 查询
+ 重置
+
+
+
+
+
+
+
+
+ {{ row.business_name }}
+
+
+
+
+
+ {{ businessStageText(row.stage) }}
+
+
+
+
+
+ {{ pipelineLevelText(row.level) }}
+
+
+
+
+ {{ formatMoney(row.amount) }}
+
+
+ {{ formatDateOnly(row.expect_deal_date) }}
+
+
+ {{ row.owner_user_name || "-" }}
+
+
+ {{ formatDateTime(row.next_contact_time) }}
+
+
+
+ {{ businessStatusText(row.status) }}
+
+
+
+
+
+ 编辑
+
+
+ 转项目
+
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ currentRow.business_name }}
+ {{ currentRow.customer_name || "-" }}
+ {{ clueSourceText(currentRow.source) }}
+
+ {{ businessStageText(currentRow.stage) }}
+
+
+ {{ pipelineLevelText(currentRow.level) }}
+
+ {{ formatMoney(currentRow.amount) }}
+ {{ formatDateOnly(currentRow.expect_deal_date) }}
+ {{ currentRow.owner_user_name || "-" }}
+ {{ formatDateTime(currentRow.next_contact_time) }}
+ {{ currentRow.contact_person || "-" }}
+ {{ currentRow.contact_position || "-" }}
+ {{ currentRow.contact_phone || "-" }}
+ {{ currentRow.address || "-" }}
+
+ {{ businessStatusText(currentRow.status) }}
+
+ {{ formatDateTime(currentRow.convert_time) }}
+ {{ currentRow.remark || "-" }}
+
+
+
+
+
+
+
+
diff --git a/backend/src/views/apps/crm/clue/components/convert.vue b/backend/src/views/apps/crm/clue/components/convert.vue
new file mode 100644
index 0000000..fa060e1
--- /dev/null
+++ b/backend/src/views/apps/crm/clue/components/convert.vue
@@ -0,0 +1,280 @@
+
+
+
+
+ 客户落地
+
+
+ 转为正式客户
+ 关联已有客户
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 商机信息
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 元
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 确认转化
+
+
+
+
+
diff --git a/backend/src/views/apps/crm/clue/components/detail.vue b/backend/src/views/apps/crm/clue/components/detail.vue
new file mode 100644
index 0000000..f187651
--- /dev/null
+++ b/backend/src/views/apps/crm/clue/components/detail.vue
@@ -0,0 +1,554 @@
+
+
+
+
+
+
+
+ {{ clue.clue_name }}
+ {{ clue.customer_name || "-" }}
+ {{ clueSourceText(clue.source) }}
+
+
+ {{ pipelineLevelText(clue.clue_level) }}
+
+
+ {{ clue.owner_user_name || "-" }}
+ {{ clue.industry || "-" }}
+ {{ formatDateTime(clue.next_contact_time) }}
+ {{ clue.contact_person || "-" }}
+ {{ clue.contact_position || "-" }}
+ {{ clue.contact_phone || "-" }}
+ {{ clue.contact_wechat || "-" }}
+ {{ clue.contact_qq || "-" }}
+ {{ clue.address || "-" }}
+
+ {{ clueStatusText(clue.status) }}
+
+ {{ formatDateTime(clue.convert_time) }}
+ {{ clue.remark || "-" }}
+ {{ formatDateTime(clue.create_time) }}
+ {{ formatDateTime(clue.update_time) }}
+
+
+
+
+
+
+ 新增联系人
+
+
+
+
+ {{ row.contact_name }}
+ 主
+
+
+
+
+
+
+
+
+
+ 编辑
+ 删除
+
+
+
+
+
+
+
+
+
+ 新增回访
+
+
+
+ {{ formatDateTime(row.follow_time) }}
+
+
+ {{ followTypeText(row.follow_type) }}
+
+
+
+
+
+
+
+ {{ formatDateTime(row.next_contact_time) }}
+
+
+
+
+ 编辑
+ 删除
+
+
+
+
+
+
+
+
+
+
+ 上传附件
+
+ 用于存放前期资料,如方案、报价单、图纸等
+
+
+
+
+ {{ row.file_name || "-" }}
+
+
+
+ {{ formatFileSize(row.file_size) }}
+
+
+
+ {{ formatDateTime(row.create_time) }}
+
+
+
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+ {{ log.operator_name || "系统" }}
+ {{ log.content || log.action }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 保存
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 保存
+
+
+
+
+
+
+
+
diff --git a/backend/src/views/apps/crm/clue/components/edit.vue b/backend/src/views/apps/crm/clue/components/edit.vue
new file mode 100644
index 0000000..aa92199
--- /dev/null
+++ b/backend/src/views/apps/crm/clue/components/edit.vue
@@ -0,0 +1,264 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 保存
+
+
+
+
+
+
+
diff --git a/backend/src/views/apps/crm/clue/index.vue b/backend/src/views/apps/crm/clue/index.vue
new file mode 100644
index 0000000..86ea307
--- /dev/null
+++ b/backend/src/views/apps/crm/clue/index.vue
@@ -0,0 +1,238 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 查询
+ 重置
+
+
+
+
+
+
+
+
+ {{ row.clue_name }}
+
+
+
+
+ {{ clueSourceText(row.source) }}
+
+
+
+
+ {{ pipelineLevelText(row.clue_level) }}
+
+
+
+
+
+ {{ row.owner_user_name || "-" }}
+
+
+ {{ row.contact_person || "-" }}
+
+
+ {{ row.contact_phone || "-" }}
+
+
+ {{ formatDateTime(row.next_contact_time) }}
+
+
+
+ {{ clueStatusText(row.status) }}
+
+
+
+
+
+ 编辑
+
+
+ 转化
+
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/src/views/apps/crm/components/RichContentEditor.vue b/backend/src/views/apps/crm/components/RichContentEditor.vue
new file mode 100644
index 0000000..cace8ee
--- /dev/null
+++ b/backend/src/views/apps/crm/components/RichContentEditor.vue
@@ -0,0 +1,199 @@
+
+
+
+
+ 上传图片
+
+ 支持 Ctrl+V 直接粘贴截图
+
+
+
+
+
+
+
+
diff --git a/backend/src/views/apps/crm/contact/index.vue b/backend/src/views/apps/crm/contact/index.vue
index 5bfb4e0..79f3b99 100644
--- a/backend/src/views/apps/crm/contact/index.vue
+++ b/backend/src/views/apps/crm/contact/index.vue
@@ -46,9 +46,20 @@
+
+
+ {{ item }}
+
+
+
-
-
+
@@ -78,18 +89,9 @@
{{ formatDateTime(row.update_time) }}
-
+
编辑
-
- 设为主联系人
-
删除
@@ -102,11 +104,10 @@
@@ -165,10 +166,11 @@
diff --git a/backend/src/views/apps/crm/follow/index.vue b/backend/src/views/apps/crm/follow/index.vue
new file mode 100644
index 0000000..defb227
--- /dev/null
+++ b/backend/src/views/apps/crm/follow/index.vue
@@ -0,0 +1,175 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 查询
+ 重置
+
+
+
+
+
+
+
+
+ {{ pipelineRelatedTypeText(row.related_type) }}
+
+
+
+
+ {{ followTypeText(row.follow_type) }}
+
+
+ {{ formatDateTime(row.follow_time) }}
+
+
+ {{ stripHtml(row.content) }}
+
+
+ {{ formatDateTime(row.next_contact_time) }}
+
+
+
+
+ 编辑
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/src/views/apps/crm/payback/index.vue b/backend/src/views/apps/crm/payback/index.vue
new file mode 100644
index 0000000..e69de29
diff --git a/backend/src/views/apps/crm/project/components/edit.vue b/backend/src/views/apps/crm/project/components/edit.vue
new file mode 100644
index 0000000..4e19c15
--- /dev/null
+++ b/backend/src/views/apps/crm/project/components/edit.vue
@@ -0,0 +1,239 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 元
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 保存
+
+
+
+
+
diff --git a/backend/src/views/apps/crm/project/index.vue b/backend/src/views/apps/crm/project/index.vue
new file mode 100644
index 0000000..c5ea6d8
--- /dev/null
+++ b/backend/src/views/apps/crm/project/index.vue
@@ -0,0 +1,234 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 查询
+ 重置
+
+
+
+
+
+
+
+
+ {{ row.project_name }}
+
+
+
+
+
+ {{ formatMoney(row.amount) }}
+
+
+
+ {{ projectStatusText(row.status) }}
+
+
+
+ {{ formatDateOnly(row.start_date) }}
+
+
+ {{ formatDateOnly(row.end_date) }}
+
+
+ {{ row.owner_user_name || "-" }}
+
+
+ {{ row.contact_person || "-" }}
+
+
+
+
+ 通讯录
+
+ 编辑
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ currentRow.project_name }}
+ {{ currentRow.project_no || "-" }}
+ {{ currentRow.customer_name || "-" }}
+ {{ formatMoney(currentRow.amount) }}
+
+
+ {{ projectStatusText(currentRow.status) }}
+
+
+ {{ formatDateOnly(currentRow.start_date) }}
+ {{ formatDateOnly(currentRow.end_date) }}
+ {{ currentRow.owner_user_name || "-" }}
+ {{ currentRow.contact_person || "-" }}
+ {{ currentRow.contact_position || "-" }}
+ {{ currentRow.contact_phone || "-" }}
+ {{ currentRow.address || "-" }}
+ {{ currentRow.remark || "-" }}
+
+
+
+
+
+
+
+
diff --git a/backend/src/views/apps/crm/styles/crm-page.less b/backend/src/views/apps/crm/styles/crm-page.less
new file mode 100644
index 0000000..12c2b7d
--- /dev/null
+++ b/backend/src/views/apps/crm/styles/crm-page.less
@@ -0,0 +1,60 @@
+/* CRM 业务管线页面通用样式(线索 / 商机 / 项目 / 回访) */
+.crm-page {
+ padding: 20px;
+}
+
+.page-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: 16px;
+
+ h2 {
+ margin: 0 0 6px 0;
+ font-size: 20px;
+ font-weight: 600;
+ color: var(--el-text-color-primary);
+ }
+
+ p {
+ margin: 0;
+ font-size: 13px;
+ color: var(--el-text-color-secondary);
+ }
+
+ .header-actions {
+ display: flex;
+ gap: 8px;
+ }
+}
+
+.filter-bar {
+ background: var(--el-bg-color);
+ padding: 16px 16px 0;
+ border-radius: 6px;
+ margin-bottom: 12px;
+ border: 1px solid var(--el-border-color-lighter);
+}
+
+.table-container {
+ background: var(--el-bg-color);
+ padding: 16px;
+ border-radius: 6px;
+ border: 1px solid var(--el-border-color-lighter);
+}
+
+.name-link {
+ cursor: pointer;
+ font-weight: 500;
+ color: var(--el-color-primary);
+
+ &:hover {
+ text-decoration: underline;
+ }
+}
+
+.pagination {
+ margin-top: 16px;
+ display: flex;
+ justify-content: flex-end;
+}
diff --git a/go/controllers/backend_crm_business.go b/go/controllers/backend_crm_business.go
new file mode 100644
index 0000000..f819781
--- /dev/null
+++ b/go/controllers/backend_crm_business.go
@@ -0,0 +1,438 @@
+package controllers
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "strconv"
+ "strings"
+ "time"
+
+ "server/models"
+
+ "github.com/beego/beego/v2/client/orm"
+ beego "github.com/beego/beego/v2/server/web"
+)
+
+// BackendCrmBusinessController CRM 商机管理
+type BackendCrmBusinessController struct {
+ beego.Controller
+}
+
+type businessPayload struct {
+ BusinessName string `json:"business_name"`
+ CustomerID uint64 `json:"customer_id"`
+ CustomerName string `json:"customer_name"`
+ Source string `json:"source"`
+ OwnerUserID string `json:"owner_user_id"`
+ OwnerUserName string `json:"owner_user_name"`
+ Industry string `json:"industry"`
+ Stage string `json:"stage"`
+ Amount float64 `json:"amount"`
+ ExpectDealDate string `json:"expect_deal_date"`
+ NextContactTime string `json:"next_contact_time"`
+ Level string `json:"level"`
+ ContactPerson string `json:"contact_person"`
+ ContactPosition string `json:"contact_position"`
+ ContactPhone string `json:"contact_phone"`
+ ContactWechat string `json:"contact_wechat"`
+ ContactQQ string `json:"contact_qq"`
+ Address string `json:"address"`
+ Remark string `json:"remark"`
+}
+
+// List GET /backend/crm/business/list
+func (c *BackendCrmBusinessController) List() {
+ claims, err := pipelineClaims(&c.Controller)
+ if err != nil {
+ pipelineErr(&c.Controller, 401, 401, err.Error())
+ return
+ }
+ page, _ := c.GetInt("page", 1)
+ pageSize, _ := c.GetInt("pageSize", 20)
+ if page < 1 {
+ page = 1
+ }
+ if pageSize < 1 || pageSize > 100 {
+ pageSize = 20
+ }
+ keyword := strings.TrimSpace(c.GetString("keyword"))
+ stage := strings.TrimSpace(c.GetString("stage"))
+ level := strings.TrimSpace(c.GetString("level"))
+ status := strings.TrimSpace(c.GetString("status"))
+ ownerID := strings.TrimSpace(c.GetString("owner_user_id"))
+
+ tenantID := pipelineTenantID(claims)
+ cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
+ if keyword != "" {
+ kw := orm.NewCondition().
+ Or("business_name__contains", keyword).
+ Or("customer_name__contains", keyword).
+ Or("contact_person__contains", keyword)
+ cond = cond.AndCond(kw)
+ }
+ if stage != "" {
+ cond = cond.And("stage", stage)
+ }
+ if level != "" {
+ cond = cond.And("level", level)
+ }
+ if status != "" {
+ cond = cond.And("status", status)
+ }
+ if ownerID != "" {
+ cond = cond.And("owner_user_id", ownerID)
+ }
+ qs := models.Orm.QueryTable(new(models.TenantCrmBusiness)).SetCond(cond)
+
+ total, _ := qs.Count()
+ var list []models.TenantCrmBusiness
+ if total > 0 {
+ _, _ = qs.OrderBy("-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
+ }
+ pipelineOk(&c.Controller, map[string]interface{}{
+ "list": list, "total": total, "page": page, "pageSize": pageSize,
+ })
+}
+
+// Detail GET /backend/crm/business/:id
+func (c *BackendCrmBusinessController) Detail() {
+ 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
+ }
+ var biz models.TenantCrmBusiness
+ err = models.Orm.QueryTable(new(models.TenantCrmBusiness)).
+ Filter("id", id).Filter("tenant_id", pipelineTenantID(claims)).
+ Filter("delete_time__isnull", true).One(&biz)
+ if err != nil {
+ pipelineErr(&c.Controller, 404, 404, "商机未找到")
+ return
+ }
+ pipelineOk(&c.Controller, biz)
+}
+
+// Create POST /backend/crm/business
+func (c *BackendCrmBusinessController) Create() {
+ 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 businessPayload
+ if err := json.Unmarshal(raw, &p); err != nil {
+ pipelineErr(&c.Controller, 400, 400, "参数错误")
+ return
+ }
+ if strings.TrimSpace(p.BusinessName) == "" {
+ pipelineErr(&c.Controller, 400, 400, "商机名称不能为空")
+ return
+ }
+ if strings.TrimSpace(p.CustomerName) == "" && p.CustomerID == 0 {
+ pipelineErr(&c.Controller, 400, 400, "请选择或填写客户")
+ return
+ }
+
+ tenantID := pipelineTenantID(claims)
+ customerName := strings.TrimSpace(p.CustomerName)
+ var custIDVal *uint64
+ if p.CustomerID > 0 {
+ var cust models.TenantCrmCustomer
+ if err := models.Orm.QueryTable(new(models.TenantCrmCustomer)).
+ Filter("id", p.CustomerID).Filter("tenant_id", tenantID).
+ Filter("delete_time__isnull", true).One(&cust); err == nil {
+ cid := cust.ID
+ custIDVal = &cid
+ customerName = cust.CustomerName
+ }
+ }
+ if customerName == "" {
+ pipelineErr(&c.Controller, 400, 400, "客户名称不能为空")
+ return
+ }
+
+ ownerID := firstNonEmpty(p.OwnerUserID, pipelineUID(claims))
+ ownerName := firstNonEmpty(p.OwnerUserName, claims.Username)
+ stage := firstNonEmpty(p.Stage, "1")
+ level := firstNonEmpty(p.Level, "2")
+ now := time.Now()
+
+ biz := models.TenantCrmBusiness{
+ TenantID: tenantID,
+ BusinessName: strings.TrimSpace(p.BusinessName),
+ CustomerID: custIDVal,
+ CustomerName: customerName,
+ Source: strings.TrimSpace(p.Source),
+ OwnerUserID: ownerID,
+ OwnerUserName: ownerName,
+ Industry: strings.TrimSpace(p.Industry),
+ Stage: stage,
+ Amount: p.Amount,
+ ExpectDealDate: parsePipelineDate(p.ExpectDealDate),
+ NextContactTime: parsePipelineDateTime(p.NextContactTime),
+ Level: level,
+ ContactPerson: strings.TrimSpace(p.ContactPerson),
+ ContactPosition: strings.TrimSpace(p.ContactPosition),
+ ContactPhone: strings.TrimSpace(p.ContactPhone),
+ ContactWechat: strings.TrimSpace(p.ContactWechat),
+ ContactQQ: strings.TrimSpace(p.ContactQQ),
+ Address: strings.TrimSpace(p.Address),
+ Status: 1,
+ Remark: p.Remark,
+ CreateUserID: pipelineUID(claims),
+ CreateTime: now,
+ UpdateTime: now,
+ }
+ id, err := models.Orm.Insert(&biz)
+ if err != nil {
+ pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
+ return
+ }
+ crmWriteLog(tenantID, 2, uint64(id), "create", "创建商机:"+biz.BusinessName, claims)
+ pipelineOk(&c.Controller, map[string]interface{}{"id": id})
+}
+
+// Update PUT /backend/crm/business/:id
+func (c *BackendCrmBusinessController) Update() {
+ 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
+ }
+ raw, _ := io.ReadAll(c.Ctx.Request.Body)
+ var p businessPayload
+ if err := json.Unmarshal(raw, &p); err != nil {
+ pipelineErr(&c.Controller, 400, 400, "参数错误")
+ return
+ }
+ tenantID := pipelineTenantID(claims)
+ var biz models.TenantCrmBusiness
+ if err := models.Orm.QueryTable(new(models.TenantCrmBusiness)).
+ Filter("id", id).Filter("tenant_id", tenantID).
+ Filter("delete_time__isnull", true).One(&biz); err != nil {
+ pipelineErr(&c.Controller, 404, 404, "商机未找到")
+ return
+ }
+ if biz.Locked == 1 {
+ pipelineErr(&c.Controller, 400, 400, "商机已转为项目并锁定,不能编辑")
+ return
+ }
+ if strings.TrimSpace(p.BusinessName) == "" {
+ pipelineErr(&c.Controller, 400, 400, "商机名称不能为空")
+ return
+ }
+
+ biz.BusinessName = strings.TrimSpace(p.BusinessName)
+ if p.CustomerID > 0 {
+ var cust models.TenantCrmCustomer
+ if err := models.Orm.QueryTable(new(models.TenantCrmCustomer)).
+ Filter("id", p.CustomerID).Filter("tenant_id", tenantID).
+ Filter("delete_time__isnull", true).One(&cust); err == nil {
+ cid := cust.ID
+ biz.CustomerID = &cid
+ biz.CustomerName = cust.CustomerName
+ }
+ } else if strings.TrimSpace(p.CustomerName) != "" {
+ biz.CustomerName = strings.TrimSpace(p.CustomerName)
+ }
+ biz.Source = strings.TrimSpace(p.Source)
+ if strings.TrimSpace(p.OwnerUserID) != "" {
+ biz.OwnerUserID = strings.TrimSpace(p.OwnerUserID)
+ }
+ if strings.TrimSpace(p.OwnerUserName) != "" {
+ biz.OwnerUserName = strings.TrimSpace(p.OwnerUserName)
+ }
+ biz.Industry = strings.TrimSpace(p.Industry)
+ if strings.TrimSpace(p.Stage) != "" {
+ biz.Stage = strings.TrimSpace(p.Stage)
+ }
+ biz.Amount = p.Amount
+ biz.ExpectDealDate = parsePipelineDate(p.ExpectDealDate)
+ biz.NextContactTime = parsePipelineDateTime(p.NextContactTime)
+ if strings.TrimSpace(p.Level) != "" {
+ biz.Level = strings.TrimSpace(p.Level)
+ }
+ biz.ContactPerson = strings.TrimSpace(p.ContactPerson)
+ biz.ContactPosition = strings.TrimSpace(p.ContactPosition)
+ biz.ContactPhone = strings.TrimSpace(p.ContactPhone)
+ biz.ContactWechat = strings.TrimSpace(p.ContactWechat)
+ biz.ContactQQ = strings.TrimSpace(p.ContactQQ)
+ biz.Address = strings.TrimSpace(p.Address)
+ biz.Remark = p.Remark
+ biz.UpdateTime = time.Now()
+
+ if _, err := models.Orm.Update(&biz); err != nil {
+ pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error())
+ return
+ }
+ crmWriteLog(tenantID, 2, biz.ID, "update", "更新商机:"+biz.BusinessName, claims)
+ pipelineOk(&c.Controller, map[string]interface{}{"id": biz.ID})
+}
+
+// Delete DELETE /backend/crm/business/:id
+func (c *BackendCrmBusinessController) Delete() {
+ 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 biz models.TenantCrmBusiness
+ if err := models.Orm.QueryTable(new(models.TenantCrmBusiness)).
+ Filter("id", id).Filter("tenant_id", tenantID).
+ Filter("delete_time__isnull", true).One(&biz); err != nil {
+ pipelineErr(&c.Controller, 404, 404, "商机未找到")
+ return
+ }
+ if !canDeleteCrmRecord(claims, biz.CreateUserID) {
+ pipelineErr(&c.Controller, 403, 403, "只有创建人、租户管理员或平台管理员可以删除该商机")
+ return
+ }
+ now := time.Now()
+ _, err = models.Orm.QueryTable(new(models.TenantCrmBusiness)).
+ Filter("id", id).Filter("tenant_id", tenantID).
+ Update(map[string]interface{}{"delete_time": now, "update_time": now})
+ if err != nil {
+ pipelineErr(&c.Controller, 500, 500, "删除失败: "+err.Error())
+ return
+ }
+ pipelineOk(&c.Controller, nil)
+}
+
+// Convert POST /backend/crm/business/:id/convert 商机转项目
+func (c *BackendCrmBusinessController) Convert() {
+ 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
+ }
+ var p struct {
+ ProjectName string `json:"project_name"`
+ ProjectNo string `json:"project_no"`
+ Amount float64 `json:"amount"`
+ StartDate string `json:"start_date"`
+ EndDate string `json:"end_date"`
+ Remark string `json:"remark"`
+ }
+ raw, _ := io.ReadAll(c.Ctx.Request.Body)
+ if err := json.Unmarshal(raw, &p); err != nil {
+ pipelineErr(&c.Controller, 400, 400, "参数错误")
+ return
+ }
+
+ tenantID := pipelineTenantID(claims)
+ var biz models.TenantCrmBusiness
+ if err := models.Orm.QueryTable(new(models.TenantCrmBusiness)).
+ Filter("id", id).Filter("tenant_id", tenantID).
+ Filter("delete_time__isnull", true).One(&biz); err != nil {
+ pipelineErr(&c.Controller, 404, 404, "商机未找到")
+ return
+ }
+ if biz.Locked == 1 || biz.Status == 2 {
+ pipelineErr(&c.Controller, 400, 400, "商机已转化,不能重复转化")
+ return
+ }
+
+ projectName := strings.TrimSpace(p.ProjectName)
+ if projectName == "" {
+ projectName = biz.BusinessName
+ }
+ amount := p.Amount
+ if amount == 0 {
+ amount = biz.Amount
+ }
+ now := time.Now()
+ bizIDVal := biz.ID
+ proj := models.TenantCrmProject{
+ TenantID: tenantID,
+ ProjectName: projectName,
+ ProjectNo: strings.TrimSpace(p.ProjectNo),
+ BusinessID: &bizIDVal,
+ CustomerID: biz.CustomerID,
+ CustomerName: biz.CustomerName,
+ OwnerUserID: biz.OwnerUserID,
+ OwnerUserName: biz.OwnerUserName,
+ Industry: biz.Industry,
+ Amount: amount,
+ Status: 1,
+ StartDate: parsePipelineDate(p.StartDate),
+ EndDate: parsePipelineDate(p.EndDate),
+ ContactPerson: biz.ContactPerson,
+ ContactPosition: biz.ContactPosition,
+ ContactPhone: biz.ContactPhone,
+ Address: biz.Address,
+ Remark: p.Remark,
+ CreateUserID: pipelineUID(claims),
+ CreateTime: now,
+ UpdateTime: now,
+ }
+ projID, err := models.Orm.Insert(&proj)
+ if err != nil {
+ pipelineErr(&c.Controller, 500, 500, "创建项目失败: "+err.Error())
+ return
+ }
+
+ // 锁定商机
+ biz.Status = 2
+ biz.Locked = 1
+ pid := uint64(projID)
+ biz.ProjectID = &pid
+ biz.ConvertTime = &now
+ biz.UpdateTime = now
+ _, _ = models.Orm.Update(&biz, "status", "locked", "project_id", "convert_time", "update_time")
+
+ crmWriteLog(tenantID, 2, biz.ID, "convert", "商机转化为项目:"+projectName, claims)
+ crmWriteLog(tenantID, 3, uint64(projID), "create", "由商机「"+biz.BusinessName+"」转化生成", claims)
+
+ // 生成项目时,把「商机 + 来源线索」过程库中的联系人去重同步到正式公司联系人库
+ if biz.CustomerID != nil && *biz.CustomerID > 0 {
+ var contacts []models.TenantCrmEntityContact
+ var bizContacts []models.TenantCrmEntityContact
+ _, _ = models.Orm.QueryTable(new(models.TenantCrmEntityContact)).
+ Filter("tenant_id", tenantID).
+ Filter("related_type", int8(2)).
+ Filter("related_id", biz.ID).
+ Filter("delete_time__isnull", true).
+ All(&bizContacts)
+ contacts = append(contacts, bizContacts...)
+ if biz.ClueID != nil && *biz.ClueID > 0 {
+ var clueContacts []models.TenantCrmEntityContact
+ _, _ = models.Orm.QueryTable(new(models.TenantCrmEntityContact)).
+ Filter("tenant_id", tenantID).
+ Filter("related_type", int8(1)).
+ Filter("related_id", *biz.ClueID).
+ Filter("delete_time__isnull", true).
+ All(&clueContacts)
+ contacts = append(contacts, clueContacts...)
+ }
+ insertedN, mergedN := syncContactsToCompany(tenantID, *biz.CustomerID, contacts)
+ if insertedN+mergedN > 0 {
+ crmWriteLog(tenantID, 3, uint64(projID), "sync-contacts",
+ fmt.Sprintf("同步联系人到正式库:新增 %d,合并 %d", insertedN, mergedN), claims)
+ }
+ }
+
+ pipelineOk(&c.Controller, map[string]interface{}{"project_id": projID})
+}
diff --git a/go/controllers/backend_crm_clue.go b/go/controllers/backend_crm_clue.go
new file mode 100644
index 0000000..daf5dfd
--- /dev/null
+++ b/go/controllers/backend_crm_clue.go
@@ -0,0 +1,476 @@
+package controllers
+
+import (
+ "encoding/json"
+ "io"
+ "strconv"
+ "strings"
+ "time"
+
+ "server/models"
+
+ "github.com/beego/beego/v2/client/orm"
+ beego "github.com/beego/beego/v2/server/web"
+)
+
+// BackendCrmClueController CRM 线索管理
+type BackendCrmClueController struct {
+ beego.Controller
+}
+
+// cluePayload 线索新增/编辑请求体
+type cluePayload struct {
+ ClueName string `json:"clue_name"`
+ CustomerName string `json:"customer_name"`
+ Source string `json:"source"`
+ OwnerUserID string `json:"owner_user_id"`
+ OwnerUserName string `json:"owner_user_name"`
+ Industry string `json:"industry"`
+ NextContactTime string `json:"next_contact_time"`
+ ClueLevel string `json:"clue_level"`
+ ContactPerson string `json:"contact_person"`
+ ContactPosition string `json:"contact_position"`
+ ContactPhone string `json:"contact_phone"`
+ ContactWechat string `json:"contact_wechat"`
+ ContactQQ string `json:"contact_qq"`
+ Address string `json:"address"`
+ Remark string `json:"remark"`
+}
+
+// List GET /backend/crm/clue/list
+func (c *BackendCrmClueController) List() {
+ claims, err := pipelineClaims(&c.Controller)
+ if err != nil {
+ pipelineErr(&c.Controller, 401, 401, err.Error())
+ return
+ }
+
+ page, _ := c.GetInt("page", 1)
+ pageSize, _ := c.GetInt("pageSize", 20)
+ if page < 1 {
+ page = 1
+ }
+ if pageSize < 1 || pageSize > 100 {
+ pageSize = 20
+ }
+ keyword := strings.TrimSpace(c.GetString("keyword"))
+ source := strings.TrimSpace(c.GetString("source"))
+ level := strings.TrimSpace(c.GetString("clue_level"))
+ status := strings.TrimSpace(c.GetString("status"))
+ ownerID := strings.TrimSpace(c.GetString("owner_user_id"))
+
+ tenantID := pipelineTenantID(claims)
+ cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
+ if keyword != "" {
+ kw := orm.NewCondition().
+ Or("clue_name__contains", keyword).
+ Or("customer_name__contains", keyword).
+ Or("contact_person__contains", keyword).
+ Or("contact_phone__contains", keyword)
+ cond = cond.AndCond(kw)
+ }
+ if source != "" {
+ cond = cond.And("source", source)
+ }
+ if level != "" {
+ cond = cond.And("clue_level", level)
+ }
+ if status != "" {
+ cond = cond.And("status", status)
+ }
+ if ownerID != "" {
+ cond = cond.And("owner_user_id", ownerID)
+ }
+ qs := models.Orm.QueryTable(new(models.TenantCrmClue)).SetCond(cond)
+
+ total, _ := qs.Count()
+ var list []models.TenantCrmClue
+ if total > 0 {
+ _, _ = qs.OrderBy("-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
+ }
+ pipelineOk(&c.Controller, map[string]interface{}{
+ "list": list, "total": total, "page": page, "pageSize": pageSize,
+ })
+}
+
+// Detail GET /backend/crm/clue/:id
+func (c *BackendCrmClueController) Detail() {
+ 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
+ }
+ var clue models.TenantCrmClue
+ err = models.Orm.QueryTable(new(models.TenantCrmClue)).
+ Filter("id", id).Filter("tenant_id", pipelineTenantID(claims)).
+ Filter("delete_time__isnull", true).One(&clue)
+ if err != nil {
+ pipelineErr(&c.Controller, 404, 404, "线索未找到")
+ return
+ }
+ pipelineOk(&c.Controller, clue)
+}
+
+// Create POST /backend/crm/clue
+func (c *BackendCrmClueController) Create() {
+ 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 cluePayload
+ if err := json.Unmarshal(raw, &p); err != nil {
+ pipelineErr(&c.Controller, 400, 400, "参数错误")
+ return
+ }
+ if strings.TrimSpace(p.ClueName) == "" {
+ pipelineErr(&c.Controller, 400, 400, "线索名称不能为空")
+ return
+ }
+ if strings.TrimSpace(p.CustomerName) == "" {
+ pipelineErr(&c.Controller, 400, 400, "客户名称不能为空")
+ return
+ }
+
+ now := time.Now()
+ ownerID := strings.TrimSpace(p.OwnerUserID)
+ ownerName := strings.TrimSpace(p.OwnerUserName)
+ if ownerID == "" {
+ ownerID = pipelineUID(claims)
+ }
+ if ownerName == "" {
+ ownerName = claims.Username
+ }
+ level := strings.TrimSpace(p.ClueLevel)
+ if level == "" {
+ level = "2"
+ }
+
+ clue := models.TenantCrmClue{
+ TenantID: pipelineTenantID(claims),
+ ClueName: strings.TrimSpace(p.ClueName),
+ CustomerName: strings.TrimSpace(p.CustomerName),
+ Source: strings.TrimSpace(p.Source),
+ OwnerUserID: ownerID,
+ OwnerUserName: ownerName,
+ Industry: strings.TrimSpace(p.Industry),
+ NextContactTime: parsePipelineDateTime(p.NextContactTime),
+ ClueLevel: level,
+ ContactPerson: strings.TrimSpace(p.ContactPerson),
+ ContactPosition: strings.TrimSpace(p.ContactPosition),
+ ContactPhone: strings.TrimSpace(p.ContactPhone),
+ ContactWechat: strings.TrimSpace(p.ContactWechat),
+ ContactQQ: strings.TrimSpace(p.ContactQQ),
+ Address: strings.TrimSpace(p.Address),
+ Status: 1,
+ Locked: 0,
+ Remark: p.Remark,
+ CreateUserID: pipelineUID(claims),
+ CreateTime: now,
+ UpdateTime: now,
+ }
+ id, err := models.Orm.Insert(&clue)
+ if err != nil {
+ pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
+ return
+ }
+ crmWriteLog(clue.TenantID, 1, uint64(id), "create", "创建线索:"+clue.ClueName, claims)
+ pipelineOk(&c.Controller, map[string]interface{}{"id": id})
+}
+
+// Update PUT /backend/crm/clue/:id
+func (c *BackendCrmClueController) Update() {
+ 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
+ }
+ raw, _ := io.ReadAll(c.Ctx.Request.Body)
+ var p cluePayload
+ if err := json.Unmarshal(raw, &p); err != nil {
+ pipelineErr(&c.Controller, 400, 400, "参数错误")
+ return
+ }
+
+ var clue models.TenantCrmClue
+ err = models.Orm.QueryTable(new(models.TenantCrmClue)).
+ Filter("id", id).Filter("tenant_id", pipelineTenantID(claims)).
+ Filter("delete_time__isnull", true).One(&clue)
+ if err != nil {
+ pipelineErr(&c.Controller, 404, 404, "线索未找到")
+ return
+ }
+ if clue.Locked == 1 {
+ pipelineErr(&c.Controller, 400, 400, "线索已转化并锁定,不能编辑")
+ return
+ }
+ if strings.TrimSpace(p.ClueName) == "" {
+ pipelineErr(&c.Controller, 400, 400, "线索名称不能为空")
+ return
+ }
+
+ clue.ClueName = strings.TrimSpace(p.ClueName)
+ clue.CustomerName = strings.TrimSpace(p.CustomerName)
+ clue.Source = strings.TrimSpace(p.Source)
+ if strings.TrimSpace(p.OwnerUserID) != "" {
+ clue.OwnerUserID = strings.TrimSpace(p.OwnerUserID)
+ }
+ if strings.TrimSpace(p.OwnerUserName) != "" {
+ clue.OwnerUserName = strings.TrimSpace(p.OwnerUserName)
+ }
+ clue.Industry = strings.TrimSpace(p.Industry)
+ clue.NextContactTime = parsePipelineDateTime(p.NextContactTime)
+ if strings.TrimSpace(p.ClueLevel) != "" {
+ clue.ClueLevel = strings.TrimSpace(p.ClueLevel)
+ }
+ clue.ContactPerson = strings.TrimSpace(p.ContactPerson)
+ clue.ContactPosition = strings.TrimSpace(p.ContactPosition)
+ clue.ContactPhone = strings.TrimSpace(p.ContactPhone)
+ clue.ContactWechat = strings.TrimSpace(p.ContactWechat)
+ clue.ContactQQ = strings.TrimSpace(p.ContactQQ)
+ clue.Address = strings.TrimSpace(p.Address)
+ clue.Remark = p.Remark
+ clue.UpdateTime = time.Now()
+
+ if _, err := models.Orm.Update(&clue); err != nil {
+ pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error())
+ return
+ }
+ crmWriteLog(clue.TenantID, 1, clue.ID, "update", "更新线索:"+clue.ClueName, claims)
+ pipelineOk(&c.Controller, map[string]interface{}{"id": clue.ID})
+}
+
+// Delete DELETE /backend/crm/clue/:id
+func (c *BackendCrmClueController) Delete() {
+ 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
+ }
+ var clue models.TenantCrmClue
+ err = models.Orm.QueryTable(new(models.TenantCrmClue)).
+ Filter("id", id).Filter("tenant_id", pipelineTenantID(claims)).
+ Filter("delete_time__isnull", true).One(&clue)
+ if err != nil {
+ pipelineErr(&c.Controller, 404, 404, "线索未找到")
+ return
+ }
+ if !canDeleteCrmRecord(claims, clue.CreateUserID) {
+ pipelineErr(&c.Controller, 403, 403, "只有创建人、租户管理员或平台管理员可以删除该线索")
+ return
+ }
+ now := time.Now()
+ _, err = models.Orm.QueryTable(new(models.TenantCrmClue)).
+ Filter("id", id).Filter("tenant_id", pipelineTenantID(claims)).
+ Update(map[string]interface{}{"delete_time": now, "update_time": now})
+ if err != nil {
+ pipelineErr(&c.Controller, 500, 500, "删除失败: "+err.Error())
+ return
+ }
+ pipelineOk(&c.Controller, nil)
+}
+
+// Convert POST /backend/crm/clue/:id/convert
+// 线索转商机:把线索的客户名称落地为正式客户(或关联已有客户),再创建商机并锁定线索。
+func (c *BackendCrmClueController) Convert() {
+ 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
+ }
+
+ var p struct {
+ BusinessName string `json:"business_name"`
+ Stage string `json:"stage"`
+ Amount float64 `json:"amount"`
+ ExpectDealDate string `json:"expect_deal_date"`
+ NextContactTime string `json:"next_contact_time"`
+ Level string `json:"level"`
+ Remark string `json:"remark"`
+ CustomerMode string `json:"customer_mode"` // create=新建客户(默认) / existing=关联已有客户
+ CustomerID uint64 `json:"customer_id"`
+ Customer struct {
+ CustomerName string `json:"customer_name"`
+ CustomerType string `json:"customer_type"`
+ Industry string `json:"industry"`
+ ContactPerson string `json:"contact_person"`
+ ContactPhone string `json:"contact_phone"`
+ ContactEmail string `json:"contact_email"`
+ Address string `json:"address"`
+ Remark string `json:"remark"`
+ } `json:"customer"`
+ }
+ raw, _ := io.ReadAll(c.Ctx.Request.Body)
+ if err := json.Unmarshal(raw, &p); err != nil {
+ pipelineErr(&c.Controller, 400, 400, "参数错误")
+ return
+ }
+
+ tenantID := pipelineTenantID(claims)
+ var clue models.TenantCrmClue
+ if err := models.Orm.QueryTable(new(models.TenantCrmClue)).
+ Filter("id", id).Filter("tenant_id", tenantID).
+ Filter("delete_time__isnull", true).One(&clue); err != nil {
+ pipelineErr(&c.Controller, 404, 404, "线索未找到")
+ return
+ }
+ if clue.Locked == 1 || clue.Status == 2 {
+ pipelineErr(&c.Controller, 400, 400, "线索已转化,不能重复转化")
+ return
+ }
+
+ // 1. 处理客户:新建正式客户 或 关联已有客户
+ customerName := strings.TrimSpace(p.Customer.CustomerName)
+ if customerName == "" {
+ customerName = clue.CustomerName
+ }
+ var customerID uint64
+ if strings.TrimSpace(p.CustomerMode) == "existing" && p.CustomerID > 0 {
+ var cust models.TenantCrmCustomer
+ if err := models.Orm.QueryTable(new(models.TenantCrmCustomer)).
+ Filter("id", p.CustomerID).Filter("tenant_id", tenantID).
+ Filter("delete_time__isnull", true).One(&cust); err != nil {
+ pipelineErr(&c.Controller, 404, 404, "关联的客户未找到")
+ return
+ }
+ customerID = cust.ID
+ customerName = cust.CustomerName
+ } else {
+ if customerName == "" {
+ pipelineErr(&c.Controller, 400, 400, "客户名称不能为空")
+ return
+ }
+ now := time.Now()
+ custType := strings.TrimSpace(p.Customer.CustomerType)
+ if custType == "" {
+ custType = "1"
+ }
+ cust := models.TenantCrmCustomer{
+ TenantID: tenantID,
+ CustomerName: customerName,
+ CustomerType: custType,
+ ContactPerson: firstNonEmpty(p.Customer.ContactPerson, clue.ContactPerson),
+ ContactPhone: firstNonEmpty(p.Customer.ContactPhone, clue.ContactPhone),
+ ContactEmail: strings.TrimSpace(p.Customer.ContactEmail),
+ CustomerLevel: "3",
+ Industry: firstNonEmpty(p.Customer.Industry, clue.Industry),
+ Address: firstNonEmpty(p.Customer.Address, clue.Address),
+ Status: "1",
+ IsDraft: 0,
+ Remark: strings.TrimSpace(p.Customer.Remark),
+ OwnerUserID: clue.OwnerUserID,
+ OwnerUserName: clue.OwnerUserName,
+ CreateUserID: pipelineUID(claims),
+ CreateTime: now,
+ UpdateTime: now,
+ }
+ newID, err := models.Orm.Insert(&cust)
+ if err != nil {
+ pipelineErr(&c.Controller, 500, 500, "生成正式客户失败: "+err.Error())
+ return
+ }
+ customerID = uint64(newID)
+ crmWriteLog(tenantID, 1, clue.ID, "convert-customer", "线索转化生成正式客户:"+customerName, claims)
+ }
+
+ // 2. 创建商机
+ businessName := strings.TrimSpace(p.BusinessName)
+ if businessName == "" {
+ businessName = clue.ClueName
+ }
+ level := strings.TrimSpace(p.Level)
+ if level == "" {
+ level = clue.ClueLevel
+ }
+ stage := strings.TrimSpace(p.Stage)
+ if stage == "" {
+ stage = "1"
+ }
+ nextContact := parsePipelineDateTime(p.NextContactTime)
+ if nextContact == nil {
+ nextContact = clue.NextContactTime
+ }
+ now := time.Now()
+ clueIDVal := clue.ID
+ custIDVal := customerID
+ biz := models.TenantCrmBusiness{
+ TenantID: tenantID,
+ BusinessName: businessName,
+ ClueID: &clueIDVal,
+ CustomerID: &custIDVal,
+ CustomerName: customerName,
+ Source: clue.Source,
+ OwnerUserID: clue.OwnerUserID,
+ OwnerUserName: clue.OwnerUserName,
+ Industry: clue.Industry,
+ Stage: stage,
+ Amount: p.Amount,
+ ExpectDealDate: parsePipelineDate(p.ExpectDealDate),
+ NextContactTime: nextContact,
+ Level: level,
+ ContactPerson: clue.ContactPerson,
+ ContactPosition: clue.ContactPosition,
+ ContactPhone: clue.ContactPhone,
+ ContactWechat: clue.ContactWechat,
+ ContactQQ: clue.ContactQQ,
+ Address: clue.Address,
+ Status: 1,
+ Locked: 0,
+ Remark: p.Remark,
+ CreateUserID: pipelineUID(claims),
+ CreateTime: now,
+ UpdateTime: now,
+ }
+ bizID, err := models.Orm.Insert(&biz)
+ if err != nil {
+ pipelineErr(&c.Controller, 500, 500, "创建商机失败: "+err.Error())
+ return
+ }
+
+ // 3. 锁定线索
+ clue.Status = 2
+ clue.Locked = 1
+ bid := uint64(bizID)
+ clue.BusinessID = &bid
+ clue.ConvertTime = &now
+ clue.UpdateTime = now
+ _, _ = models.Orm.Update(&clue, "status", "locked", "business_id", "convert_time", "update_time")
+
+ crmWriteLog(tenantID, 1, clue.ID, "convert", "线索转化为商机:"+businessName, claims)
+ crmWriteLog(tenantID, 2, uint64(bizID), "create", "由线索「"+clue.ClueName+"」转化生成", claims)
+
+ pipelineOk(&c.Controller, map[string]interface{}{
+ "business_id": bizID, "customer_id": customerID,
+ })
+}
+
+// firstNonEmpty 返回第一个非空字符串。
+func firstNonEmpty(vals ...string) string {
+ for _, v := range vals {
+ if strings.TrimSpace(v) != "" {
+ return strings.TrimSpace(v)
+ }
+ }
+ return ""
+}
diff --git a/go/controllers/backend_crm_pipeline_common.go b/go/controllers/backend_crm_pipeline_common.go
new file mode 100644
index 0000000..ebec04a
--- /dev/null
+++ b/go/controllers/backend_crm_pipeline_common.go
@@ -0,0 +1,247 @@
+package controllers
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "server/models"
+ "server/pkg/jwtutil"
+
+ beego "github.com/beego/beego/v2/server/web"
+)
+
+// CRM 业务管线(线索/商机/项目/回访)公共辅助方法。
+// related_type 约定:1=线索 2=商机 3=项目
+
+// pipelineClaims 解析租户端后台 JWT。
+func pipelineClaims(c *beego.Controller) (*jwtutil.Claims, error) {
+ auth := c.Ctx.Request.Header.Get("Authorization")
+ if auth == "" {
+ return nil, fmt.Errorf("未登录")
+ }
+ parts := strings.SplitN(auth, " ", 2)
+ if len(parts) != 2 || parts[0] != "Bearer" {
+ return nil, fmt.Errorf("认证信息格式错误")
+ }
+ claims, err := jwtutil.ParseToken(parts[1])
+ if err != nil {
+ return nil, fmt.Errorf("无效的token")
+ }
+ if claims.UserType != "backend" && claims.UserType != "platform" {
+ return nil, fmt.Errorf("无权访问")
+ }
+ return claims, nil
+}
+
+// pipelineOk 统一成功响应。
+func pipelineOk(c *beego.Controller, data interface{}) {
+ c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data}
+ _ = c.ServeJSON()
+}
+
+// pipelineErr 统一错误响应。
+func pipelineErr(c *beego.Controller, httpStatus, bizCode int, msg string) {
+ c.Ctx.Output.SetStatus(httpStatus)
+ c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
+ _ = c.ServeJSON()
+}
+
+// pipelineTenantID 返回当前租户ID字符串。
+func pipelineTenantID(claims *jwtutil.Claims) string {
+ return fmt.Sprintf("%d", claims.TenantId)
+}
+
+// pipelineUID 返回当前用户ID字符串。
+func pipelineUID(claims *jwtutil.Claims) string {
+ return fmt.Sprintf("%d", claims.UserID)
+}
+
+// parsePipelineDateTime 解析时间:支持 "2006-01-02 15:04:05"、"2006-01-02T15:04:05"、"2006-01-02"。
+func parsePipelineDateTime(s string) *time.Time {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return nil
+ }
+ layouts := []string{
+ "2006-01-02 15:04:05",
+ "2006-01-02T15:04:05",
+ "2006-01-02T15:04",
+ "2006-01-02 15:04",
+ "2006-01-02",
+ }
+ for _, layout := range layouts {
+ if t, err := time.ParseInLocation(layout, s, time.Local); err == nil {
+ return &t
+ }
+ }
+ return nil
+}
+
+// parsePipelineDate 解析日期(仅取年月日,返回当天零点)。
+func parsePipelineDate(s string) *time.Time {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return nil
+ }
+ if t, err := time.ParseInLocation("2006-01-02", s, time.Local); err == nil {
+ return &t
+ }
+ return parsePipelineDateTime(s)
+}
+
+// crmWriteLog 写入 CRM 操作日志(失败静默忽略,不影响主流程)。
+func crmWriteLog(tenantID string, relatedType int8, relatedID uint64, action, content string, claims *jwtutil.Claims) {
+ if models.Orm == nil || relatedID == 0 {
+ return
+ }
+ operatorID, operatorName := "", ""
+ if claims != nil {
+ operatorID = pipelineUID(claims)
+ operatorName = claims.Username
+ }
+ log := models.TenantCrmOperateLog{
+ TenantID: tenantID,
+ RelatedType: relatedType,
+ RelatedID: relatedID,
+ Action: action,
+ Content: content,
+ OperatorID: operatorID,
+ OperatorName: operatorName,
+ CreateTime: time.Now(),
+ }
+ _, _ = models.Orm.Insert(&log)
+}
+
+// relatedTypeText 关联类型文案。
+func relatedTypeText(t int8) string {
+ switch t {
+ case 1:
+ return "线索"
+ case 2:
+ return "商机"
+ case 3:
+ return "项目"
+ default:
+ return "未知"
+ }
+}
+
+// mobileInMobiles 判断手机号是否已存在于 mobiles(JSON 数组或普通字符串)中。
+func mobileInMobiles(mobile, mobiles string) bool {
+ mobile = strings.TrimSpace(mobile)
+ if mobile == "" || strings.TrimSpace(mobiles) == "" {
+ return false
+ }
+ if firstMobile(mobiles) == mobile {
+ return true
+ }
+ return strings.Contains(mobiles, mobile)
+}
+
+// mergeCompanyContact 合并过程库联系人到正式库联系人:仅补齐正式库空缺字段,不覆盖已有数据。
+func mergeCompanyContact(dst *models.ErpCompanyContact, src models.TenantCrmEntityContact) {
+ changed := false
+ if dst.Wechat == "" && strings.TrimSpace(src.Wechat) != "" {
+ dst.Wechat = strings.TrimSpace(src.Wechat)
+ changed = true
+ }
+ if dst.QQ == "" && strings.TrimSpace(src.QQ) != "" {
+ dst.QQ = strings.TrimSpace(src.QQ)
+ changed = true
+ }
+ if dst.Email == "" && strings.TrimSpace(src.Email) != "" {
+ dst.Email = strings.TrimSpace(src.Email)
+ changed = true
+ }
+ if dst.Position == "" && strings.TrimSpace(src.Position) != "" {
+ dst.Position = strings.TrimSpace(src.Position)
+ changed = true
+ }
+ if strings.TrimSpace(dst.Mobiles) == "" && strings.TrimSpace(src.Mobile) != "" {
+ dst.Mobiles = buildMobiles(src.Mobile)
+ changed = true
+ }
+ if dst.Remark == "" && strings.TrimSpace(src.Remark) != "" {
+ dst.Remark = strings.TrimSpace(src.Remark)
+ changed = true
+ }
+ if src.IsPrimary == 1 && dst.IsPrimary != 1 {
+ dst.IsPrimary = 1
+ changed = true
+ }
+ if changed {
+ dst.UpdateTime = time.Now()
+ _, _ = models.Orm.Update(dst)
+ }
+}
+
+// syncContactsToCompany 把线索/商机过程库(yz_tenant_crm_entity_contact)的联系人
+// 去重同步到正式公司联系人库(yz_backend_contact_company,company_type=customer)。
+// 去重键:company_type + company_id + name + mobile;命中则补齐字段,否则新增。
+// 返回 (新增数, 合并数)。
+func syncContactsToCompany(tenantID string, customerID uint64, contacts []models.TenantCrmEntityContact) (int, int) {
+ if models.Orm == nil || customerID == 0 || len(contacts) == 0 {
+ return 0, 0
+ }
+ inserted, merged := 0, 0
+ seen := map[string]bool{}
+ for _, src := range contacts {
+ name := strings.TrimSpace(src.ContactName)
+ if name == "" {
+ continue
+ }
+ mobile := strings.TrimSpace(src.Mobile)
+ // 本次待同步集合内去重(同名同手机只处理一次)
+ key := name + "|" + mobile
+ if seen[key] {
+ continue
+ }
+ seen[key] = true
+
+ var existingList []models.ErpCompanyContact
+ _, _ = models.Orm.QueryTable(new(models.ErpCompanyContact)).
+ Filter("tenant_id", tenantID).
+ Filter("company_type", "customer").
+ Filter("company_id", customerID).
+ Filter("name", name).
+ Filter("delete_time__isnull", true).
+ All(&existingList)
+
+ matched := false
+ for i := range existingList {
+ // 有手机号时必须手机号匹配;无手机号时按同名视为同一人
+ if mobile == "" || mobileInMobiles(mobile, existingList[i].Mobiles) {
+ mergeCompanyContact(&existingList[i], src)
+ merged++
+ matched = true
+ break
+ }
+ }
+ if matched {
+ continue
+ }
+
+ row := models.ErpCompanyContact{
+ TenantID: tenantID,
+ CompanyType: "customer",
+ CompanyID: customerID,
+ Name: name,
+ Phone: "",
+ Mobiles: buildMobiles(mobile),
+ Wechat: strings.TrimSpace(src.Wechat),
+ QQ: strings.TrimSpace(src.QQ),
+ Email: strings.TrimSpace(src.Email),
+ Position: strings.TrimSpace(src.Position),
+ IsPrimary: src.IsPrimary,
+ Status: 1,
+ Remark: strings.TrimSpace(src.Remark),
+ CreateTime: time.Now(),
+ UpdateTime: time.Now(),
+ }
+ if _, err := models.Orm.Insert(&row); err == nil {
+ inserted++
+ }
+ }
+ return inserted, merged
+}
diff --git a/go/controllers/backend_crm_project.go b/go/controllers/backend_crm_project.go
new file mode 100644
index 0000000..3787cd5
--- /dev/null
+++ b/go/controllers/backend_crm_project.go
@@ -0,0 +1,282 @@
+package controllers
+
+import (
+ "encoding/json"
+ "io"
+ "strconv"
+ "strings"
+ "time"
+
+ "server/models"
+
+ "github.com/beego/beego/v2/client/orm"
+ beego "github.com/beego/beego/v2/server/web"
+)
+
+// BackendCrmProjectController CRM 项目管理
+type BackendCrmProjectController struct {
+ beego.Controller
+}
+
+type projectPayload struct {
+ ProjectName string `json:"project_name"`
+ ProjectNo string `json:"project_no"`
+ CustomerID uint64 `json:"customer_id"`
+ CustomerName string `json:"customer_name"`
+ OwnerUserID string `json:"owner_user_id"`
+ OwnerUserName string `json:"owner_user_name"`
+ Industry string `json:"industry"`
+ Amount float64 `json:"amount"`
+ Status int8 `json:"status"`
+ StartDate string `json:"start_date"`
+ EndDate string `json:"end_date"`
+ ContactPerson string `json:"contact_person"`
+ ContactPosition string `json:"contact_position"`
+ ContactPhone string `json:"contact_phone"`
+ Address string `json:"address"`
+ Remark string `json:"remark"`
+}
+
+// List GET /backend/crm/project/list
+func (c *BackendCrmProjectController) List() {
+ claims, err := pipelineClaims(&c.Controller)
+ if err != nil {
+ pipelineErr(&c.Controller, 401, 401, err.Error())
+ return
+ }
+ page, _ := c.GetInt("page", 1)
+ pageSize, _ := c.GetInt("pageSize", 20)
+ if page < 1 {
+ page = 1
+ }
+ if pageSize < 1 || pageSize > 100 {
+ pageSize = 20
+ }
+ keyword := strings.TrimSpace(c.GetString("keyword"))
+ status := strings.TrimSpace(c.GetString("status"))
+ ownerID := strings.TrimSpace(c.GetString("owner_user_id"))
+
+ tenantID := pipelineTenantID(claims)
+ cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
+ if keyword != "" {
+ kw := orm.NewCondition().
+ Or("project_name__contains", keyword).
+ Or("project_no__contains", keyword).
+ Or("customer_name__contains", keyword)
+ cond = cond.AndCond(kw)
+ }
+ if status != "" {
+ cond = cond.And("status", status)
+ }
+ if ownerID != "" {
+ cond = cond.And("owner_user_id", ownerID)
+ }
+ qs := models.Orm.QueryTable(new(models.TenantCrmProject)).SetCond(cond)
+
+ total, _ := qs.Count()
+ var list []models.TenantCrmProject
+ if total > 0 {
+ _, _ = qs.OrderBy("-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
+ }
+ pipelineOk(&c.Controller, map[string]interface{}{
+ "list": list, "total": total, "page": page, "pageSize": pageSize,
+ })
+}
+
+// Detail GET /backend/crm/project/:id
+func (c *BackendCrmProjectController) Detail() {
+ 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
+ }
+ var proj models.TenantCrmProject
+ err = models.Orm.QueryTable(new(models.TenantCrmProject)).
+ Filter("id", id).Filter("tenant_id", pipelineTenantID(claims)).
+ Filter("delete_time__isnull", true).One(&proj)
+ if err != nil {
+ pipelineErr(&c.Controller, 404, 404, "项目未找到")
+ return
+ }
+ pipelineOk(&c.Controller, proj)
+}
+
+// Create POST /backend/crm/project
+func (c *BackendCrmProjectController) Create() {
+ 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 projectPayload
+ if err := json.Unmarshal(raw, &p); err != nil {
+ pipelineErr(&c.Controller, 400, 400, "参数错误")
+ return
+ }
+ if strings.TrimSpace(p.ProjectName) == "" {
+ pipelineErr(&c.Controller, 400, 400, "项目名称不能为空")
+ return
+ }
+
+ tenantID := pipelineTenantID(claims)
+ var custIDVal *uint64
+ customerName := strings.TrimSpace(p.CustomerName)
+ if p.CustomerID > 0 {
+ var cust models.TenantCrmCustomer
+ if err := models.Orm.QueryTable(new(models.TenantCrmCustomer)).
+ Filter("id", p.CustomerID).Filter("tenant_id", tenantID).
+ Filter("delete_time__isnull", true).One(&cust); err == nil {
+ cid := cust.ID
+ custIDVal = &cid
+ customerName = cust.CustomerName
+ }
+ }
+ status := p.Status
+ if status == 0 {
+ status = 1
+ }
+ now := time.Now()
+ proj := models.TenantCrmProject{
+ TenantID: tenantID,
+ ProjectName: strings.TrimSpace(p.ProjectName),
+ ProjectNo: strings.TrimSpace(p.ProjectNo),
+ CustomerID: custIDVal,
+ CustomerName: customerName,
+ OwnerUserID: firstNonEmpty(p.OwnerUserID, pipelineUID(claims)),
+ OwnerUserName: firstNonEmpty(p.OwnerUserName, claims.Username),
+ Industry: strings.TrimSpace(p.Industry),
+ Amount: p.Amount,
+ Status: status,
+ StartDate: parsePipelineDate(p.StartDate),
+ EndDate: parsePipelineDate(p.EndDate),
+ ContactPerson: strings.TrimSpace(p.ContactPerson),
+ ContactPosition: strings.TrimSpace(p.ContactPosition),
+ ContactPhone: strings.TrimSpace(p.ContactPhone),
+ Address: strings.TrimSpace(p.Address),
+ Remark: p.Remark,
+ CreateUserID: pipelineUID(claims),
+ CreateTime: now,
+ UpdateTime: now,
+ }
+ id, err := models.Orm.Insert(&proj)
+ if err != nil {
+ pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
+ return
+ }
+ crmWriteLog(tenantID, 3, uint64(id), "create", "创建项目:"+proj.ProjectName, claims)
+ pipelineOk(&c.Controller, map[string]interface{}{"id": id})
+}
+
+// Update PUT /backend/crm/project/:id
+func (c *BackendCrmProjectController) Update() {
+ 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
+ }
+ raw, _ := io.ReadAll(c.Ctx.Request.Body)
+ var p projectPayload
+ if err := json.Unmarshal(raw, &p); err != nil {
+ pipelineErr(&c.Controller, 400, 400, "参数错误")
+ return
+ }
+ tenantID := pipelineTenantID(claims)
+ var proj models.TenantCrmProject
+ if err := models.Orm.QueryTable(new(models.TenantCrmProject)).
+ Filter("id", id).Filter("tenant_id", tenantID).
+ Filter("delete_time__isnull", true).One(&proj); err != nil {
+ pipelineErr(&c.Controller, 404, 404, "项目未找到")
+ return
+ }
+ if strings.TrimSpace(p.ProjectName) == "" {
+ pipelineErr(&c.Controller, 400, 400, "项目名称不能为空")
+ return
+ }
+
+ proj.ProjectName = strings.TrimSpace(p.ProjectName)
+ proj.ProjectNo = strings.TrimSpace(p.ProjectNo)
+ if p.CustomerID > 0 {
+ var cust models.TenantCrmCustomer
+ if err := models.Orm.QueryTable(new(models.TenantCrmCustomer)).
+ Filter("id", p.CustomerID).Filter("tenant_id", tenantID).
+ Filter("delete_time__isnull", true).One(&cust); err == nil {
+ cid := cust.ID
+ proj.CustomerID = &cid
+ proj.CustomerName = cust.CustomerName
+ }
+ } else if strings.TrimSpace(p.CustomerName) != "" {
+ proj.CustomerName = strings.TrimSpace(p.CustomerName)
+ }
+ if strings.TrimSpace(p.OwnerUserID) != "" {
+ proj.OwnerUserID = strings.TrimSpace(p.OwnerUserID)
+ }
+ if strings.TrimSpace(p.OwnerUserName) != "" {
+ proj.OwnerUserName = strings.TrimSpace(p.OwnerUserName)
+ }
+ proj.Industry = strings.TrimSpace(p.Industry)
+ proj.Amount = p.Amount
+ if p.Status != 0 {
+ proj.Status = p.Status
+ }
+ proj.StartDate = parsePipelineDate(p.StartDate)
+ proj.EndDate = parsePipelineDate(p.EndDate)
+ proj.ContactPerson = strings.TrimSpace(p.ContactPerson)
+ proj.ContactPosition = strings.TrimSpace(p.ContactPosition)
+ proj.ContactPhone = strings.TrimSpace(p.ContactPhone)
+ proj.Address = strings.TrimSpace(p.Address)
+ proj.Remark = p.Remark
+ proj.UpdateTime = time.Now()
+
+ if _, err := models.Orm.Update(&proj); err != nil {
+ pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error())
+ return
+ }
+ crmWriteLog(tenantID, 3, proj.ID, "update", "更新项目:"+proj.ProjectName, claims)
+ pipelineOk(&c.Controller, map[string]interface{}{"id": proj.ID})
+}
+
+// Delete DELETE /backend/crm/project/:id
+func (c *BackendCrmProjectController) Delete() {
+ 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 proj models.TenantCrmProject
+ if err := models.Orm.QueryTable(new(models.TenantCrmProject)).
+ Filter("id", id).Filter("tenant_id", tenantID).
+ Filter("delete_time__isnull", true).One(&proj); err != nil {
+ pipelineErr(&c.Controller, 404, 404, "项目未找到")
+ return
+ }
+ if !canDeleteCrmRecord(claims, proj.CreateUserID) {
+ pipelineErr(&c.Controller, 403, 403, "只有创建人、租户管理员或平台管理员可以删除该项目")
+ return
+ }
+ now := time.Now()
+ _, err = models.Orm.QueryTable(new(models.TenantCrmProject)).
+ Filter("id", id).Filter("tenant_id", tenantID).
+ Update(map[string]interface{}{"delete_time": now, "update_time": now})
+ if err != nil {
+ pipelineErr(&c.Controller, 500, 500, "删除失败: "+err.Error())
+ return
+ }
+ pipelineOk(&c.Controller, nil)
+}
diff --git a/go/controllers/backend_crm_support.go b/go/controllers/backend_crm_support.go
new file mode 100644
index 0000000..d66b82e
--- /dev/null
+++ b/go/controllers/backend_crm_support.go
@@ -0,0 +1,540 @@
+package controllers
+
+import (
+ "encoding/json"
+ "io"
+ "strings"
+ "time"
+
+ "server/models"
+
+ "github.com/beego/beego/v2/client/orm"
+ beego "github.com/beego/beego/v2/server/web"
+)
+
+// ============================== 回访 / 跟进记录 ==============================
+
+// BackendCrmFollowController 回访记录(贯穿线索/商机/项目)
+type BackendCrmFollowController struct {
+ beego.Controller
+}
+
+// List GET /backend/crm/follow/list
+func (c *BackendCrmFollowController) List() {
+ claims, err := pipelineClaims(&c.Controller)
+ if err != nil {
+ pipelineErr(&c.Controller, 401, 401, err.Error())
+ return
+ }
+ page, _ := c.GetInt("page", 1)
+ pageSize, _ := c.GetInt("pageSize", 20)
+ if page < 1 {
+ page = 1
+ }
+ if pageSize < 1 || pageSize > 100 {
+ pageSize = 20
+ }
+ tenantID := pipelineTenantID(claims)
+ relatedType := strings.TrimSpace(c.GetString("related_type"))
+ relatedID := strings.TrimSpace(c.GetString("related_id"))
+ followType := strings.TrimSpace(c.GetString("follow_type"))
+ keyword := strings.TrimSpace(c.GetString("keyword"))
+
+ cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
+ if relatedType != "" {
+ cond = cond.And("related_type", relatedType)
+ }
+ if relatedID != "" && relatedID != "0" {
+ cond = cond.And("related_id", relatedID)
+ }
+ if followType != "" {
+ cond = cond.And("follow_type", followType)
+ }
+ if keyword != "" {
+ kw := orm.NewCondition().
+ Or("related_name__contains", keyword).
+ Or("content__contains", keyword)
+ cond = cond.AndCond(kw)
+ }
+ qs := models.Orm.QueryTable(new(models.TenantCrmFollow)).SetCond(cond)
+ total, _ := qs.Count()
+ var list []models.TenantCrmFollow
+ if total > 0 {
+ _, _ = qs.OrderBy("-follow_time", "-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
+ }
+ pipelineOk(&c.Controller, map[string]interface{}{
+ "list": list, "total": total, "page": page, "pageSize": pageSize,
+ })
+}
+
+type followPayload struct {
+ ID uint64 `json:"id"`
+ RelatedType int8 `json:"related_type"`
+ RelatedID uint64 `json:"related_id"`
+ RelatedName string `json:"related_name"`
+ FollowType string `json:"follow_type"`
+ FollowTime string `json:"follow_time"`
+ Content string `json:"content"`
+ NextContactTime string `json:"next_contact_time"`
+}
+
+// Add POST /backend/crm/follow/add
+func (c *BackendCrmFollowController) Add() {
+ 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 followPayload
+ if err := json.Unmarshal(raw, &p); err != nil {
+ pipelineErr(&c.Controller, 400, 400, "参数错误")
+ return
+ }
+ if p.RelatedID == 0 || (p.RelatedType != 1 && p.RelatedType != 2 && p.RelatedType != 3) {
+ pipelineErr(&c.Controller, 400, 400, "请指定关联对象")
+ return
+ }
+ if strings.TrimSpace(p.Content) == "" {
+ pipelineErr(&c.Controller, 400, 400, "回访内容不能为空")
+ return
+ }
+ tenantID := pipelineTenantID(claims)
+ followTime := parsePipelineDateTime(p.FollowTime)
+ if followTime == nil {
+ t := time.Now()
+ followTime = &t
+ }
+ nextContact := parsePipelineDateTime(p.NextContactTime)
+ row := models.TenantCrmFollow{
+ TenantID: tenantID,
+ RelatedType: p.RelatedType,
+ RelatedID: p.RelatedID,
+ RelatedName: strings.TrimSpace(p.RelatedName),
+ FollowType: firstNonEmpty(p.FollowType, "1"),
+ FollowTime: followTime,
+ Content: p.Content,
+ NextContactTime: nextContact,
+ OwnerUserID: pipelineUID(claims),
+ OwnerUserName: claims.Username,
+ CreateTime: time.Now(),
+ UpdateTime: time.Now(),
+ }
+ id, err := models.Orm.Insert(&row)
+ if err != nil {
+ pipelineErr(&c.Controller, 500, 500, "新增失败: "+err.Error())
+ return
+ }
+ // 同步更新关联对象的下次联系时间
+ if nextContact != nil {
+ syncNextContact(tenantID, p.RelatedType, p.RelatedID, nextContact)
+ }
+ crmWriteLog(tenantID, p.RelatedType, p.RelatedID, "follow", "新增回访记录:"+strings.TrimSpace(p.RelatedName), claims)
+ pipelineOk(&c.Controller, map[string]interface{}{"id": id})
+}
+
+// Edit POST /backend/crm/follow/edit
+func (c *BackendCrmFollowController) Edit() {
+ 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 followPayload
+ if err := json.Unmarshal(raw, &p); err != nil {
+ pipelineErr(&c.Controller, 400, 400, "参数错误")
+ return
+ }
+ if p.ID == 0 {
+ pipelineErr(&c.Controller, 400, 400, "缺少ID")
+ return
+ }
+ tenantID := pipelineTenantID(claims)
+ var row models.TenantCrmFollow
+ if err := models.Orm.QueryTable(new(models.TenantCrmFollow)).
+ Filter("id", p.ID).Filter("tenant_id", tenantID).
+ Filter("delete_time__isnull", true).One(&row); err != nil {
+ pipelineErr(&c.Controller, 404, 404, "回访记录未找到")
+ return
+ }
+ row.FollowType = firstNonEmpty(p.FollowType, row.FollowType)
+ if t := parsePipelineDateTime(p.FollowTime); t != nil {
+ row.FollowTime = t
+ }
+ row.Content = p.Content
+ row.NextContactTime = parsePipelineDateTime(p.NextContactTime)
+ row.UpdateTime = time.Now()
+ if _, err := models.Orm.Update(&row); err != nil {
+ pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error())
+ return
+ }
+ pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID})
+}
+
+// Delete POST /backend/crm/follow/delete
+func (c *BackendCrmFollowController) Delete() {
+ claims, err := pipelineClaims(&c.Controller)
+ if err != nil {
+ pipelineErr(&c.Controller, 401, 401, err.Error())
+ return
+ }
+ var p struct {
+ ID uint64 `json:"id"`
+ }
+ raw, _ := io.ReadAll(c.Ctx.Request.Body)
+ if err := json.Unmarshal(raw, &p); err != nil || p.ID == 0 {
+ pipelineErr(&c.Controller, 400, 400, "参数错误")
+ return
+ }
+ now := time.Now()
+ num, err := models.Orm.QueryTable(new(models.TenantCrmFollow)).
+ Filter("id", p.ID).Filter("tenant_id", pipelineTenantID(claims)).
+ Filter("delete_time__isnull", true).
+ Update(map[string]interface{}{"delete_time": now, "update_time": now})
+ if err != nil {
+ pipelineErr(&c.Controller, 500, 500, "删除失败: "+err.Error())
+ return
+ }
+ if num == 0 {
+ pipelineErr(&c.Controller, 404, 404, "回访记录未找到")
+ return
+ }
+ pipelineOk(&c.Controller, nil)
+}
+
+// syncNextContact 回访后同步更新线索/商机的下次联系时间。
+func syncNextContact(tenantID string, relatedType int8, relatedID uint64, next *time.Time) {
+ if models.Orm == nil || next == nil {
+ return
+ }
+ now := time.Now()
+ switch relatedType {
+ case 1:
+ _, _ = models.Orm.QueryTable(new(models.TenantCrmClue)).
+ Filter("id", relatedID).Filter("tenant_id", tenantID).
+ Update(map[string]interface{}{"next_contact_time": next, "update_time": now})
+ case 2:
+ _, _ = models.Orm.QueryTable(new(models.TenantCrmBusiness)).
+ Filter("id", relatedID).Filter("tenant_id", tenantID).
+ Update(map[string]interface{}{"next_contact_time": next, "update_time": now})
+ }
+}
+
+// ================================= 附件 =================================
+
+// BackendCrmAttachController 附件(线索/商机/项目)
+type BackendCrmAttachController struct {
+ beego.Controller
+}
+
+// List GET /backend/crm/attach/list
+func (c *BackendCrmAttachController) List() {
+ claims, err := pipelineClaims(&c.Controller)
+ if err != nil {
+ pipelineErr(&c.Controller, 401, 401, err.Error())
+ return
+ }
+ relatedType := strings.TrimSpace(c.GetString("related_type"))
+ relatedID := strings.TrimSpace(c.GetString("related_id"))
+ cond := orm.NewCondition().
+ And("tenant_id", pipelineTenantID(claims)).
+ And("delete_time__isnull", true)
+ if relatedType != "" {
+ cond = cond.And("related_type", relatedType)
+ }
+ if relatedID != "" {
+ cond = cond.And("related_id", relatedID)
+ }
+ var list []models.TenantCrmAttach
+ _, _ = models.Orm.QueryTable(new(models.TenantCrmAttach)).SetCond(cond).OrderBy("-id").All(&list)
+ pipelineOk(&c.Controller, map[string]interface{}{"list": list, "total": len(list)})
+}
+
+// Add POST /backend/crm/attach/add
+func (c *BackendCrmAttachController) Add() {
+ claims, err := pipelineClaims(&c.Controller)
+ if err != nil {
+ pipelineErr(&c.Controller, 401, 401, err.Error())
+ return
+ }
+ var p struct {
+ RelatedType int8 `json:"related_type"`
+ RelatedID uint64 `json:"related_id"`
+ FileID uint64 `json:"file_id"`
+ FileName string `json:"file_name"`
+ FileURL string `json:"file_url"`
+ FileSize int64 `json:"file_size"`
+ }
+ raw, _ := io.ReadAll(c.Ctx.Request.Body)
+ if err := json.Unmarshal(raw, &p); err != nil {
+ pipelineErr(&c.Controller, 400, 400, "参数错误")
+ return
+ }
+ if p.RelatedID == 0 || strings.TrimSpace(p.FileURL) == "" {
+ pipelineErr(&c.Controller, 400, 400, "附件信息不完整")
+ return
+ }
+ tenantID := pipelineTenantID(claims)
+ var fileIDPtr *uint64
+ if p.FileID > 0 {
+ fid := p.FileID
+ fileIDPtr = &fid
+ }
+ row := models.TenantCrmAttach{
+ TenantID: tenantID,
+ RelatedType: p.RelatedType,
+ RelatedID: p.RelatedID,
+ FileID: fileIDPtr,
+ FileName: strings.TrimSpace(p.FileName),
+ FileURL: strings.TrimSpace(p.FileURL),
+ FileSize: p.FileSize,
+ UploaderID: pipelineUID(claims),
+ UploaderName: claims.Username,
+ CreateTime: time.Now(),
+ UpdateTime: time.Now(),
+ }
+ id, err := models.Orm.Insert(&row)
+ if err != nil {
+ pipelineErr(&c.Controller, 500, 500, "新增失败: "+err.Error())
+ return
+ }
+ crmWriteLog(tenantID, p.RelatedType, p.RelatedID, "attach", "新增附件:"+row.FileName, claims)
+ pipelineOk(&c.Controller, map[string]interface{}{"id": id})
+}
+
+// Delete POST /backend/crm/attach/delete
+func (c *BackendCrmAttachController) Delete() {
+ claims, err := pipelineClaims(&c.Controller)
+ if err != nil {
+ pipelineErr(&c.Controller, 401, 401, err.Error())
+ return
+ }
+ var p struct {
+ ID uint64 `json:"id"`
+ }
+ raw := c.Ctx.Input.RequestBody
+ if err := json.Unmarshal(raw, &p); err != nil || p.ID == 0 {
+ pipelineErr(&c.Controller, 400, 400, "参数错误")
+ return
+ }
+ now := time.Now()
+ num, err := models.Orm.QueryTable(new(models.TenantCrmAttach)).
+ Filter("id", p.ID).Filter("tenant_id", pipelineTenantID(claims)).
+ Filter("delete_time__isnull", true).
+ Update(map[string]interface{}{"delete_time": now, "update_time": now})
+ if err != nil {
+ pipelineErr(&c.Controller, 500, 500, "删除失败: "+err.Error())
+ return
+ }
+ if num == 0 {
+ pipelineErr(&c.Controller, 404, 404, "附件未找到")
+ return
+ }
+ pipelineOk(&c.Controller, nil)
+}
+
+// ========================== 联系人(线索/商机/项目) ==========================
+
+// BackendCrmEntityContactController 实体联系人
+type BackendCrmEntityContactController struct {
+ beego.Controller
+}
+
+// List GET /backend/crm/entity/contact/list
+func (c *BackendCrmEntityContactController) List() {
+ claims, err := pipelineClaims(&c.Controller)
+ if err != nil {
+ pipelineErr(&c.Controller, 401, 401, err.Error())
+ return
+ }
+ relatedType := strings.TrimSpace(c.GetString("related_type"))
+ relatedID := strings.TrimSpace(c.GetString("related_id"))
+ cond := orm.NewCondition().
+ And("tenant_id", pipelineTenantID(claims)).
+ And("delete_time__isnull", true)
+ if relatedType != "" {
+ cond = cond.And("related_type", relatedType)
+ }
+ if relatedID != "" {
+ cond = cond.And("related_id", relatedID)
+ }
+ var list []models.TenantCrmEntityContact
+ _, _ = models.Orm.QueryTable(new(models.TenantCrmEntityContact)).SetCond(cond).
+ OrderBy("-is_primary", "-id").All(&list)
+ pipelineOk(&c.Controller, map[string]interface{}{"list": list, "total": len(list)})
+}
+
+type entityContactPayload struct {
+ ID uint64 `json:"id"`
+ RelatedType int8 `json:"related_type"`
+ RelatedID uint64 `json:"related_id"`
+ ContactName string `json:"contact_name"`
+ Position string `json:"position"`
+ Mobile string `json:"mobile"`
+ Wechat string `json:"wechat"`
+ QQ string `json:"qq"`
+ Email string `json:"email"`
+ IsPrimary int8 `json:"is_primary"`
+ Remark string `json:"remark"`
+}
+
+// Add POST /backend/crm/entity/contact/add
+func (c *BackendCrmEntityContactController) Add() {
+ claims, err := pipelineClaims(&c.Controller)
+ if err != nil {
+ pipelineErr(&c.Controller, 401, 401, err.Error())
+ return
+ }
+ raw := c.Ctx.Input.RequestBody
+ var p entityContactPayload
+ if err := json.Unmarshal(raw, &p); err != nil {
+ pipelineErr(&c.Controller, 400, 400, "参数错误")
+ return
+ }
+ if strings.TrimSpace(p.ContactName) == "" || p.RelatedID == 0 {
+ pipelineErr(&c.Controller, 400, 400, "联系人姓名和关联对象不能为空")
+ return
+ }
+ tenantID := pipelineTenantID(claims)
+ row := models.TenantCrmEntityContact{
+ TenantID: tenantID,
+ RelatedType: p.RelatedType,
+ RelatedID: p.RelatedID,
+ ContactName: strings.TrimSpace(p.ContactName),
+ Position: strings.TrimSpace(p.Position),
+ Mobile: strings.TrimSpace(p.Mobile),
+ Wechat: strings.TrimSpace(p.Wechat),
+ QQ: strings.TrimSpace(p.QQ),
+ Email: strings.TrimSpace(p.Email),
+ IsPrimary: p.IsPrimary,
+ Remark: strings.TrimSpace(p.Remark),
+ CreateTime: time.Now(),
+ UpdateTime: time.Now(),
+ }
+ id, err := models.Orm.Insert(&row)
+ if err != nil {
+ pipelineErr(&c.Controller, 500, 500, "新增失败: "+err.Error())
+ return
+ }
+ if p.IsPrimary == 1 {
+ clearOtherEntityPrimary(tenantID, p.RelatedType, p.RelatedID, uint64(id))
+ }
+ pipelineOk(&c.Controller, map[string]interface{}{"id": id})
+}
+
+// Edit POST /backend/crm/entity/contact/edit
+func (c *BackendCrmEntityContactController) Edit() {
+ claims, err := pipelineClaims(&c.Controller)
+ if err != nil {
+ pipelineErr(&c.Controller, 401, 401, err.Error())
+ return
+ }
+ raw := c.Ctx.Input.RequestBody
+ var p entityContactPayload
+ if err := json.Unmarshal(raw, &p); err != nil || p.ID == 0 {
+ pipelineErr(&c.Controller, 400, 400, "参数错误")
+ return
+ }
+ tenantID := pipelineTenantID(claims)
+ var row models.TenantCrmEntityContact
+ if err := models.Orm.QueryTable(new(models.TenantCrmEntityContact)).
+ Filter("id", p.ID).Filter("tenant_id", tenantID).
+ Filter("delete_time__isnull", true).One(&row); err != nil {
+ pipelineErr(&c.Controller, 404, 404, "联系人未找到")
+ return
+ }
+ if strings.TrimSpace(p.ContactName) == "" {
+ pipelineErr(&c.Controller, 400, 400, "联系人姓名不能为空")
+ return
+ }
+ row.ContactName = strings.TrimSpace(p.ContactName)
+ row.Position = strings.TrimSpace(p.Position)
+ row.Mobile = strings.TrimSpace(p.Mobile)
+ row.Wechat = strings.TrimSpace(p.Wechat)
+ row.QQ = strings.TrimSpace(p.QQ)
+ row.Email = strings.TrimSpace(p.Email)
+ row.IsPrimary = p.IsPrimary
+ row.Remark = strings.TrimSpace(p.Remark)
+ row.UpdateTime = time.Now()
+ if _, err := models.Orm.Update(&row); err != nil {
+ pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error())
+ return
+ }
+ if p.IsPrimary == 1 {
+ clearOtherEntityPrimary(tenantID, row.RelatedType, row.RelatedID, row.ID)
+ }
+ pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID})
+}
+
+// Delete POST /backend/crm/entity/contact/delete
+func (c *BackendCrmEntityContactController) Delete() {
+ claims, err := pipelineClaims(&c.Controller)
+ if err != nil {
+ pipelineErr(&c.Controller, 401, 401, err.Error())
+ return
+ }
+ var p struct {
+ ID uint64 `json:"id"`
+ }
+ raw := c.Ctx.Input.RequestBody
+ if err := json.Unmarshal(raw, &p); err != nil || p.ID == 0 {
+ pipelineErr(&c.Controller, 400, 400, "参数错误")
+ return
+ }
+ now := time.Now()
+ num, err := models.Orm.QueryTable(new(models.TenantCrmEntityContact)).
+ Filter("id", p.ID).Filter("tenant_id", pipelineTenantID(claims)).
+ Filter("delete_time__isnull", true).
+ Update(map[string]interface{}{"delete_time": now, "update_time": now})
+ if err != nil {
+ pipelineErr(&c.Controller, 500, 500, "删除失败: "+err.Error())
+ return
+ }
+ if num == 0 {
+ pipelineErr(&c.Controller, 404, 404, "联系人未找到")
+ return
+ }
+ pipelineOk(&c.Controller, nil)
+}
+
+func clearOtherEntityPrimary(tenantID string, relatedType int8, relatedID, excludeID uint64) {
+ if models.Orm == nil {
+ return
+ }
+ _, _ = models.Orm.QueryTable(new(models.TenantCrmEntityContact)).
+ Filter("tenant_id", tenantID).
+ Filter("related_type", relatedType).
+ Filter("related_id", relatedID).
+ Filter("delete_time__isnull", true).
+ Exclude("id", excludeID).
+ Update(map[string]interface{}{"is_primary": 0})
+}
+
+// ============================== 操作日志 ==============================
+
+// BackendCrmOperateLogController 操作日志
+type BackendCrmOperateLogController struct {
+ beego.Controller
+}
+
+// List GET /backend/crm/oplog/list
+func (c *BackendCrmOperateLogController) List() {
+ claims, err := pipelineClaims(&c.Controller)
+ if err != nil {
+ pipelineErr(&c.Controller, 401, 401, err.Error())
+ return
+ }
+ relatedType := strings.TrimSpace(c.GetString("related_type"))
+ relatedID := strings.TrimSpace(c.GetString("related_id"))
+ cond := orm.NewCondition().And("tenant_id", pipelineTenantID(claims))
+ if relatedType != "" {
+ cond = cond.And("related_type", relatedType)
+ }
+ if relatedID != "" {
+ cond = cond.And("related_id", relatedID)
+ }
+ var list []models.TenantCrmOperateLog
+ _, _ = models.Orm.QueryTable(new(models.TenantCrmOperateLog)).SetCond(cond).OrderBy("-id").All(&list)
+ pipelineOk(&c.Controller, map[string]interface{}{"list": list, "total": len(list)})
+}
diff --git a/go/models/erp_company_contact.go b/go/models/erp_company_contact.go
index 71ff435..4717754 100644
--- a/go/models/erp_company_contact.go
+++ b/go/models/erp_company_contact.go
@@ -2,7 +2,8 @@ package models
import "time"
-// ErpCompanyContact 客户/供应商联系人表
+// ErpCompanyContact 正式客户/供应商联系人通用表: yz_backend_contact_company
+// 全模块共用的「公司联系人」正式库(客户/供应商对接人)。
type ErpCompanyContact struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
@@ -28,5 +29,5 @@ type ErpCompanyContact struct {
}
func (m *ErpCompanyContact) TableName() string {
- return "yz_backend_erp_company_contact"
+ return "yz_backend_contact_company"
}
diff --git a/go/models/init.go b/go/models/init.go
index 1d2b333..a653766 100644
--- a/go/models/init.go
+++ b/go/models/init.go
@@ -74,6 +74,13 @@ func Init(_ string) {
new(BackendUserNotifyConfig),
new(TenantCrmCustomer),
new(TenantCrmSupplier),
+ new(TenantCrmClue),
+ new(TenantCrmBusiness),
+ new(TenantCrmProject),
+ new(TenantCrmFollow),
+ new(TenantCrmAttach),
+ new(TenantCrmEntityContact),
+ new(TenantCrmOperateLog),
new(ErpAccountSet),
new(ErpNormalSetting),
new(ErpCompanyContact),
diff --git a/go/models/tenant_crm_attach.go b/go/models/tenant_crm_attach.go
new file mode 100644
index 0000000..0503fef
--- /dev/null
+++ b/go/models/tenant_crm_attach.go
@@ -0,0 +1,25 @@
+package models
+
+import "time"
+
+// TenantCrmAttach 附件表: yz_tenant_crm_attach
+// related_type: 1线索 / 2商机 / 3项目
+type TenantCrmAttach struct {
+ ID uint64 `orm:"column(id);pk;auto" json:"id"`
+ TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
+ RelatedType int8 `orm:"column(related_type)" json:"related_type"`
+ RelatedID uint64 `orm:"column(related_id)" json:"related_id"`
+ FileID *uint64 `orm:"column(file_id);null" json:"file_id"`
+ FileName string `orm:"column(file_name);size(255)" json:"file_name"`
+ FileURL string `orm:"column(file_url);size(500)" json:"file_url"`
+ FileSize int64 `orm:"column(file_size);default(0)" json:"file_size"`
+ UploaderID string `orm:"column(uploader_id);size(64)" json:"uploader_id"`
+ UploaderName string `orm:"column(uploader_name);size(128)" json:"uploader_name"`
+ 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 *TenantCrmAttach) TableName() string {
+ return "yz_tenant_crm_attach"
+}
diff --git a/go/models/tenant_crm_business.go b/go/models/tenant_crm_business.go
new file mode 100644
index 0000000..0ed2a1e
--- /dev/null
+++ b/go/models/tenant_crm_business.go
@@ -0,0 +1,41 @@
+package models
+
+import "time"
+
+// TenantCrmBusiness 商机表: yz_tenant_crm_business
+type TenantCrmBusiness struct {
+ ID uint64 `orm:"column(id);pk;auto" json:"id"`
+ TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
+ BusinessName string `orm:"column(business_name);size(100)" json:"business_name"` // 商机名称
+ ClueID *uint64 `orm:"column(clue_id);null" json:"clue_id"` // 来源线索ID
+ CustomerID *uint64 `orm:"column(customer_id);null" json:"customer_id"` // 正式客户ID
+ CustomerName string `orm:"column(customer_name);size(100)" json:"customer_name"`
+ Source string `orm:"column(source);size(50)" json:"source"`
+ 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"`
+ Industry string `orm:"column(industry);size(50)" json:"industry"`
+ Stage string `orm:"column(stage);size(20);default(1)" json:"stage"` // 商机阶段
+ Amount float64 `orm:"column(amount);digits(14);decimals(2);default(0)" json:"amount"`
+ ExpectDealDate *time.Time `orm:"column(expect_deal_date);type(date);null" json:"expect_deal_date"`
+ NextContactTime *time.Time `orm:"column(next_contact_time);type(datetime);null" json:"next_contact_time"`
+ Level string `orm:"column(level);size(20);default(2)" json:"level"`
+ ContactPerson string `orm:"column(contact_person);size(50)" json:"contact_person"`
+ ContactPosition string `orm:"column(contact_position);size(50)" json:"contact_position"` // 对接人职位
+ ContactPhone string `orm:"column(contact_phone);size(20)" json:"contact_phone"`
+ ContactWechat string `orm:"column(contact_wechat);size(64)" json:"contact_wechat"`
+ ContactQQ string `orm:"column(contact_qq);size(20)" json:"contact_qq"`
+ Address string `orm:"column(address);size(255)" json:"address"`
+ Status int8 `orm:"column(status);default(1)" json:"status"` // 1跟进中/2已转化项目/3已关闭
+ Locked int8 `orm:"column(locked);default(0)" json:"locked"` // 转化项目后锁定
+ ProjectID *uint64 `orm:"column(project_id);null" json:"project_id"` // 转化后的项目ID
+ ConvertTime *time.Time `orm:"column(convert_time);type(datetime);null" json:"convert_time"`
+ Remark string `orm:"column(remark);type(text);null" json:"remark"`
+ 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 *TenantCrmBusiness) TableName() string {
+ return "yz_tenant_crm_business"
+}
diff --git a/go/models/tenant_crm_clue.go b/go/models/tenant_crm_clue.go
new file mode 100644
index 0000000..1972143
--- /dev/null
+++ b/go/models/tenant_crm_clue.go
@@ -0,0 +1,37 @@
+package models
+
+import "time"
+
+// TenantCrmClue 线索表: yz_tenant_crm_clue
+// 线索阶段的「客户名称」不关联客户管理,转化商机时才落地为正式客户。
+type TenantCrmClue struct {
+ ID uint64 `orm:"column(id);pk;auto" json:"id"`
+ TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
+ ClueName string `orm:"column(clue_name);size(100)" json:"clue_name"` // 线索名称
+ CustomerName string `orm:"column(customer_name);size(100)" json:"customer_name"` // 客户名称(暂不关联客户管理)
+ Source string `orm:"column(source);size(50)" json:"source"` // 客户来源
+ 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"`
+ Industry string `orm:"column(industry);size(50)" json:"industry"` // 客户行业
+ NextContactTime *time.Time `orm:"column(next_contact_time);type(datetime);null" json:"next_contact_time"` // 下次联系时间
+ ClueLevel string `orm:"column(clue_level);size(20);default(2)" json:"clue_level"` // 客户级别:1重点/2普通/3非优先
+ ContactPerson string `orm:"column(contact_person);size(50)" json:"contact_person"` // 客户对接人
+ ContactPosition string `orm:"column(contact_position);size(50)" json:"contact_position"` // 对接人职位
+ ContactPhone string `orm:"column(contact_phone);size(20)" json:"contact_phone"` // 对接人手机
+ ContactWechat string `orm:"column(contact_wechat);size(64)" json:"contact_wechat"` // 对接人微信
+ ContactQQ string `orm:"column(contact_qq);size(20)" json:"contact_qq"` // 对接人QQ
+ Address string `orm:"column(address);size(255)" json:"address"` // 公司地址
+ Status int8 `orm:"column(status);default(1)" json:"status"` // 1跟进中/2已转化/3已关闭
+ Locked int8 `orm:"column(locked);default(0)" json:"locked"` // 0否/1是(转化后锁定不可编辑)
+ BusinessID *uint64 `orm:"column(business_id);null" json:"business_id"` // 转化后的商机ID
+ ConvertTime *time.Time `orm:"column(convert_time);type(datetime);null" json:"convert_time"` // 转化时间
+ Remark string `orm:"column(remark);type(text);null" json:"remark"`
+ 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 *TenantCrmClue) TableName() string {
+ return "yz_tenant_crm_clue"
+}
diff --git a/go/models/tenant_crm_entity_contact.go b/go/models/tenant_crm_entity_contact.go
new file mode 100644
index 0000000..6679692
--- /dev/null
+++ b/go/models/tenant_crm_entity_contact.go
@@ -0,0 +1,27 @@
+package models
+
+import "time"
+
+// TenantCrmEntityContact 联系人表: yz_tenant_crm_entity_contact
+// related_type: 1线索 / 2商机 / 3项目
+type TenantCrmEntityContact struct {
+ ID uint64 `orm:"column(id);pk;auto" json:"id"`
+ TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
+ RelatedType int8 `orm:"column(related_type)" json:"related_type"`
+ RelatedID uint64 `orm:"column(related_id)" json:"related_id"`
+ ContactName string `orm:"column(contact_name);size(50)" json:"contact_name"`
+ Position string `orm:"column(position);size(50)" json:"position"`
+ Mobile string `orm:"column(mobile);size(20)" json:"mobile"`
+ Wechat string `orm:"column(wechat);size(64)" json:"wechat"`
+ QQ string `orm:"column(qq);size(20)" json:"qq"`
+ Email string `orm:"column(email);size(100)" json:"email"`
+ IsPrimary int8 `orm:"column(is_primary);default(0)" json:"is_primary"`
+ Remark string `orm:"column(remark);size(500)" json:"remark"`
+ 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 *TenantCrmEntityContact) TableName() string {
+ return "yz_tenant_crm_entity_contact"
+}
diff --git a/go/models/tenant_crm_follow.go b/go/models/tenant_crm_follow.go
new file mode 100644
index 0000000..a03ae9c
--- /dev/null
+++ b/go/models/tenant_crm_follow.go
@@ -0,0 +1,26 @@
+package models
+
+import "time"
+
+// TenantCrmFollow 回访/跟进记录表: yz_tenant_crm_follow
+// related_type: 1线索 / 2商机 / 3项目
+type TenantCrmFollow struct {
+ ID uint64 `orm:"column(id);pk;auto" json:"id"`
+ TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
+ RelatedType int8 `orm:"column(related_type)" json:"related_type"`
+ RelatedID uint64 `orm:"column(related_id)" json:"related_id"`
+ RelatedName string `orm:"column(related_name);size(100)" json:"related_name"`
+ FollowType string `orm:"column(follow_type);size(20);default(1)" json:"follow_type"` // 回访方式
+ FollowTime *time.Time `orm:"column(follow_time);type(datetime);null" json:"follow_time"`
+ Content string `orm:"column(content);type(text);null" json:"content"`
+ NextContactTime *time.Time `orm:"column(next_contact_time);type(datetime);null" json:"next_contact_time"`
+ 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"`
+ 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 *TenantCrmFollow) TableName() string {
+ return "yz_tenant_crm_follow"
+}
diff --git a/go/models/tenant_crm_operate_log.go b/go/models/tenant_crm_operate_log.go
new file mode 100644
index 0000000..49de2fe
--- /dev/null
+++ b/go/models/tenant_crm_operate_log.go
@@ -0,0 +1,21 @@
+package models
+
+import "time"
+
+// TenantCrmOperateLog 操作日志表: yz_tenant_crm_operate_log
+// related_type: 1线索 / 2商机 / 3项目
+type TenantCrmOperateLog struct {
+ ID uint64 `orm:"column(id);pk;auto" json:"id"`
+ TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
+ RelatedType int8 `orm:"column(related_type)" json:"related_type"`
+ RelatedID uint64 `orm:"column(related_id)" json:"related_id"`
+ Action string `orm:"column(action);size(50)" json:"action"`
+ Content string `orm:"column(content);size(500)" json:"content"`
+ OperatorID string `orm:"column(operator_id);size(64)" json:"operator_id"`
+ OperatorName string `orm:"column(operator_name);size(128)" json:"operator_name"`
+ CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
+}
+
+func (m *TenantCrmOperateLog) TableName() string {
+ return "yz_tenant_crm_operate_log"
+}
diff --git a/go/models/tenant_crm_project.go b/go/models/tenant_crm_project.go
new file mode 100644
index 0000000..eb53887
--- /dev/null
+++ b/go/models/tenant_crm_project.go
@@ -0,0 +1,34 @@
+package models
+
+import "time"
+
+// TenantCrmProject 项目表: yz_tenant_crm_project
+type TenantCrmProject struct {
+ ID uint64 `orm:"column(id);pk;auto" json:"id"`
+ TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
+ ProjectName string `orm:"column(project_name);size(100)" json:"project_name"` // 项目名称
+ ProjectNo string `orm:"column(project_no);size(50)" json:"project_no"` // 项目编号
+ BusinessID *uint64 `orm:"column(business_id);null" json:"business_id"` // 来源商机ID
+ CustomerID *uint64 `orm:"column(customer_id);null" json:"customer_id"`
+ CustomerName string `orm:"column(customer_name);size(100)" json:"customer_name"`
+ 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"`
+ Industry string `orm:"column(industry);size(50)" json:"industry"`
+ Amount float64 `orm:"column(amount);digits(14);decimals(2);default(0)" json:"amount"`
+ Status int8 `orm:"column(status);default(1)" json:"status"` // 1未开始/2进行中/3已完成/4已暂停
+ StartDate *time.Time `orm:"column(start_date);type(date);null" json:"start_date"`
+ EndDate *time.Time `orm:"column(end_date);type(date);null" json:"end_date"`
+ ContactPerson string `orm:"column(contact_person);size(50)" json:"contact_person"`
+ ContactPosition string `orm:"column(contact_position);size(50)" json:"contact_position"` // 对接人职位
+ ContactPhone string `orm:"column(contact_phone);size(20)" json:"contact_phone"`
+ Address string `orm:"column(address);size(255)" json:"address"`
+ Remark string `orm:"column(remark);type(text);null" json:"remark"`
+ 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 *TenantCrmProject) TableName() string {
+ return "yz_tenant_crm_project"
+}
diff --git a/go/routers/backend/backend.go b/go/routers/backend/backend.go
index 48fd38a..bcd905f 100644
--- a/go/routers/backend/backend.go
+++ b/go/routers/backend/backend.go
@@ -384,6 +384,43 @@ func registerOrganizationRoutes(module string) {
beego.Router("/backend/crm/pool/claim", &controllers.BackendCrmPoolController{}, "post:Claim")
beego.Router("/backend/crm/pool/assign", &controllers.BackendCrmPoolController{}, "post:Assign")
+ // CRM线索管理(线索 -> 商机转化)
+ beego.Router("/backend/crm/clue/list", &controllers.BackendCrmClueController{}, "get:List")
+ beego.Router("/backend/crm/clue", &controllers.BackendCrmClueController{}, "post:Create")
+ beego.Router("/backend/crm/clue/:id", &controllers.BackendCrmClueController{}, "get:Detail;put:Update;delete:Delete")
+ beego.Router("/backend/crm/clue/:id/convert", &controllers.BackendCrmClueController{}, "post:Convert")
+
+ // CRM商机管理(商机 -> 项目转化)
+ beego.Router("/backend/crm/business/list", &controllers.BackendCrmBusinessController{}, "get:List")
+ beego.Router("/backend/crm/business", &controllers.BackendCrmBusinessController{}, "post:Create")
+ beego.Router("/backend/crm/business/:id", &controllers.BackendCrmBusinessController{}, "get:Detail;put:Update;delete:Delete")
+ beego.Router("/backend/crm/business/:id/convert", &controllers.BackendCrmBusinessController{}, "post:Convert")
+
+ // CRM项目管理
+ beego.Router("/backend/crm/project/list", &controllers.BackendCrmProjectController{}, "get:List")
+ beego.Router("/backend/crm/project", &controllers.BackendCrmProjectController{}, "post:Create")
+ beego.Router("/backend/crm/project/:id", &controllers.BackendCrmProjectController{}, "get:Detail;put:Update;delete:Delete")
+
+ // CRM回访记录(贯穿线索/商机/项目)
+ beego.Router("/backend/crm/follow/list", &controllers.BackendCrmFollowController{}, "get:List")
+ beego.Router("/backend/crm/follow/add", &controllers.BackendCrmFollowController{}, "post:Add")
+ beego.Router("/backend/crm/follow/edit", &controllers.BackendCrmFollowController{}, "post:Edit")
+ beego.Router("/backend/crm/follow/delete", &controllers.BackendCrmFollowController{}, "post:Delete")
+
+ // CRM附件(线索/商机/项目资料)
+ beego.Router("/backend/crm/attach/list", &controllers.BackendCrmAttachController{}, "get:List")
+ beego.Router("/backend/crm/attach/add", &controllers.BackendCrmAttachController{}, "post:Add")
+ beego.Router("/backend/crm/attach/delete", &controllers.BackendCrmAttachController{}, "post:Delete")
+
+ // CRM联系人(线索/商机/项目的对接人列表)
+ beego.Router("/backend/crm/entity/contact/list", &controllers.BackendCrmEntityContactController{}, "get:List")
+ beego.Router("/backend/crm/entity/contact/add", &controllers.BackendCrmEntityContactController{}, "post:Add")
+ beego.Router("/backend/crm/entity/contact/edit", &controllers.BackendCrmEntityContactController{}, "post:Edit")
+ beego.Router("/backend/crm/entity/contact/delete", &controllers.BackendCrmEntityContactController{}, "post:Delete")
+
+ // CRM操作日志
+ beego.Router("/backend/crm/oplog/list", &controllers.BackendCrmOperateLogController{}, "get:List")
+
// ERP供应商管理
beego.Router("/backend/erp/supplier/list", &controllers.BackendErpSupplierController{}, "get:List")
beego.Router("/backend/erp/supplier", &controllers.BackendErpSupplierController{}, "post:Create")
diff --git a/sql/alter_company_contact_unify.sql b/sql/alter_company_contact_unify.sql
new file mode 100644
index 0000000..afaff43
--- /dev/null
+++ b/sql/alter_company_contact_unify.sql
@@ -0,0 +1,198 @@
+-- =============================================================================
+-- 联系人正式库统一:正式客户/供应商联系人通用表 yz_backend_contact_company
+--
+-- 目标:
+-- 1. 正式客户/供应商联系人统一到 `yz_backend_contact_company`
+-- (backend 端「公司联系人」通用表,命名与 yz_backend_contact 保持一致);
+-- 2. 兼容历史表名 `yz_backend_erp_company_contact`:
+-- - 若新表不存在、旧表存在,直接 RENAME(结构与数据 100% 保留);
+-- - 若两表并存,则去重合并进新表,旧表改名为 *_bak 备份保留;
+-- 3. 迁移历史死表 `yz_tenant_crm_contact`(若存在且有数据):去重并入新表,
+-- 原表改名为 `yz_tenant_crm_contact_bak` 保留;
+-- 4. 线索/商机联系人仍然只存过程库 `yz_tenant_crm_entity_contact`(本脚本不动)。
+--
+-- 去重键(判定为同一联系人):tenant_id + company_type + company_id + name + mobiles
+-- 说明:
+-- - 脚本幂等,可重复执行;
+-- - 任何迁移都是「先备份原表 / 或直接改名」再按去重键补插,绝不删除数据;
+-- - 执行本脚本的同时需要部署新版后端(模型表名已指向 yz_backend_contact_company)。
+-- =============================================================================
+
+SET NAMES utf8mb4;
+
+-- 记录初始状态
+SET @has_new := (
+ SELECT COUNT(*) FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_contact_company'
+);
+SET @has_old_company := (
+ SELECT COUNT(*) FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_erp_company_contact'
+);
+
+-- -----------------------------------------------------------------------------
+-- 1. 新表不存在、旧表存在 → 直接改名(保留全部结构、索引、自增、数据)
+-- -----------------------------------------------------------------------------
+SET @sql := IF(@has_new = 0 AND @has_old_company > 0,
+ 'RENAME TABLE `yz_backend_erp_company_contact` TO `yz_backend_contact_company`',
+ 'SELECT ''step1: 跳过直接改名'' AS note');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- 重新计算状态
+SET @has_new := (
+ SELECT COUNT(*) FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_contact_company'
+);
+SET @has_old_company := (
+ SELECT COUNT(*) FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_erp_company_contact'
+);
+
+-- -----------------------------------------------------------------------------
+-- 2. 确保正式表存在(新表也不存在、旧表也不存在时创建)
+-- -----------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `yz_backend_contact_company` (
+ `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
+ `tenant_id` varchar(64) NOT NULL DEFAULT '' COMMENT '租户ID',
+ `company_type` varchar(20) NOT NULL DEFAULT '' COMMENT '公司类型:customer=客户,supplier=供应商',
+ `company_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '公司ID(客户/供应商ID)',
+ `name` varchar(50) NOT NULL DEFAULT '' COMMENT '联系人姓名',
+ `gender` tinyint(4) NOT NULL DEFAULT 0 COMMENT '性别:0-未知 1-男 2-女',
+ `phone` varchar(20) NOT NULL DEFAULT '' COMMENT '座机',
+ `mobiles` text COMMENT '手机号,JSON数组,最多5个',
+ `wechat` varchar(50) NOT NULL DEFAULT '' COMMENT '微信',
+ `qq` varchar(50) NOT NULL DEFAULT '' COMMENT 'QQ',
+ `dingtalk` varchar(50) NOT NULL DEFAULT '' COMMENT '钉钉',
+ `email` varchar(100) NOT NULL DEFAULT '' COMMENT '邮箱',
+ `department` varchar(50) NOT NULL DEFAULT '' COMMENT '部门',
+ `position` varchar(50) NOT NULL DEFAULT '' COMMENT '职位',
+ `home_address` varchar(255) NOT NULL DEFAULT '' COMMENT '家庭住址',
+ `is_primary` tinyint(4) NOT NULL DEFAULT 0 COMMENT '是否主联系人:0-否 1-是',
+ `status` tinyint(4) NOT NULL DEFAULT 1 COMMENT '状态:0-离职 1-在职',
+ `remark` text COMMENT '备注',
+ `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_company` (`tenant_id`,`company_type`,`company_id`) COMMENT '按公司查询联系人',
+ KEY `idx_name` (`tenant_id`,`name`) COMMENT '按姓名查询',
+ KEY `idx_delete_time` (`delete_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='正式客户/供应商联系人通用表(backend 公司联系人)';
+
+-- -----------------------------------------------------------------------------
+-- 3. 两表并存时:旧表 yz_backend_erp_company_contact 去重并入新表,再改名备份
+-- -----------------------------------------------------------------------------
+SET @sql := IF(@has_old_company > 0,
+'INSERT INTO `yz_backend_contact_company`
+ (`tenant_id`,`company_type`,`company_id`,`name`,`gender`,`phone`,`mobiles`,`wechat`,`qq`,`dingtalk`,`email`,`department`,`position`,`home_address`,`is_primary`,`status`,`remark`,`create_time`,`update_time`,`delete_time`)
+ SELECT
+ CAST(o.`tenant_id` AS CHAR), o.`company_type`, o.`company_id`, o.`name`, o.`gender`, o.`phone`, o.`mobiles`,
+ IFNULL(o.`wechat`, ''''), IFNULL(o.`qq`, ''''), IFNULL(o.`dingtalk`, ''''), IFNULL(o.`email`, ''''),
+ IFNULL(o.`department`, ''''), IFNULL(o.`position`, ''''), IFNULL(o.`home_address`, ''''),
+ IFNULL(o.`is_primary`, 0), IFNULL(o.`status`, 1), o.`remark`,
+ IFNULL(o.`create_time`, NOW()), IFNULL(o.`update_time`, NOW()), o.`delete_time`
+ FROM `yz_backend_erp_company_contact` o
+ WHERE NOT EXISTS (
+ SELECT 1 FROM `yz_backend_contact_company` n
+ WHERE n.`tenant_id` = CAST(o.`tenant_id` AS CHAR)
+ AND n.`company_type` = o.`company_type`
+ AND n.`company_id` = o.`company_id`
+ AND n.`name` = o.`name`
+ AND IFNULL(n.`mobiles`, '''') = IFNULL(o.`mobiles`, '''')
+ )',
+'SELECT ''step3: 无并存旧公司联系人表,跳过合并'' AS note');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @bak_company_exists := (
+ SELECT COUNT(*) FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_erp_company_contact_bak'
+);
+SET @sql := IF(@has_old_company > 0 AND @bak_company_exists = 0,
+ 'RENAME TABLE `yz_backend_erp_company_contact` TO `yz_backend_erp_company_contact_bak`',
+ 'SELECT ''step3: 跳过改名备份'' AS note');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- -----------------------------------------------------------------------------
+-- 4. 历史死表 yz_tenant_crm_contact(related_type 1=客户/2=供应商)→ 新表,原表备份
+-- mobiles 由单个 mobile 转为 JSON 数组;相关字段做空值兜底。
+-- -----------------------------------------------------------------------------
+SET @has_dead := (
+ SELECT COUNT(*) FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_contact'
+);
+
+SET @sql := IF(@has_dead > 0,
+'INSERT INTO `yz_backend_contact_company`
+ (`tenant_id`,`company_type`,`company_id`,`name`,`gender`,`phone`,`mobiles`,`wechat`,`qq`,`dingtalk`,`email`,`department`,`position`,`home_address`,`is_primary`,`status`,`remark`,`create_time`,`update_time`,`delete_time`)
+ SELECT
+ CAST(o.`tenant_id` AS CHAR),
+ IF(o.`related_type` = 2, ''supplier'', ''customer''),
+ o.`related_id`, o.`contact_name`, IFNULL(o.`gender`, 0), IFNULL(o.`phone`, ''''),
+ CASE WHEN o.`mobile` IS NULL OR o.`mobile` = '''' THEN NULL ELSE CONCAT(''["'', o.`mobile`, ''"]'') END,
+ '''', '''', '''', IFNULL(o.`email`, ''''), IFNULL(o.`department`, ''''), IFNULL(o.`position`, ''''), '''',
+ IFNULL(o.`is_primary`, 0), 1, IFNULL(o.`remark`, ''''),
+ IFNULL(o.`create_time`, NOW()), IFNULL(o.`update_time`, NOW()),
+ CASE WHEN IFNULL(o.`is_deleted`, 0) = 1 THEN NOW() ELSE NULL END
+ FROM `yz_tenant_crm_contact` o
+ WHERE o.`contact_name` IS NOT NULL AND o.`contact_name` <> ''''
+ AND NOT EXISTS (
+ SELECT 1 FROM `yz_backend_contact_company` n
+ WHERE n.`tenant_id` = CAST(o.`tenant_id` AS CHAR)
+ AND n.`company_type` = IF(o.`related_type` = 2, ''supplier'', ''customer'')
+ AND n.`company_id` = o.`related_id`
+ AND n.`name` = o.`contact_name`
+ AND IFNULL(n.`mobiles`, '''') = IFNULL(
+ CASE WHEN o.`mobile` IS NULL OR o.`mobile` = '''' THEN NULL ELSE CONCAT(''["'', o.`mobile`, ''"]'') END, '''')
+ )',
+'SELECT ''步骤4: 无 yz_tenant_crm_contact,跳过'' AS note');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @bak_dead_exists := (
+ SELECT COUNT(*) FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_contact_bak'
+);
+SET @sql := IF(@has_dead > 0 AND @bak_dead_exists = 0,
+ 'RENAME TABLE `yz_tenant_crm_contact` TO `yz_tenant_crm_contact_bak`',
+ 'SELECT ''步骤4: 跳过改名备份'' AS note');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- -----------------------------------------------------------------------------
+-- 5. 重复数据检测(只查询、不修改/不删除,供人工核对)
+-- -----------------------------------------------------------------------------
+SELECT
+ `tenant_id`,
+ `company_type`,
+ `company_id`,
+ `name`,
+ IFNULL(`mobiles`, '') AS `mobiles`,
+ COUNT(*) AS `dup_count`,
+ GROUP_CONCAT(`id` ORDER BY `id`) AS `dup_ids`
+FROM `yz_backend_contact_company`
+WHERE `delete_time` IS NULL
+GROUP BY `tenant_id`, `company_type`, `company_id`, `name`, IFNULL(`mobiles`, '')
+HAVING COUNT(*) > 1;
+
+-- -----------------------------------------------------------------------------
+-- 6. 迁移结果核对(新表与备份表条数)
+-- -----------------------------------------------------------------------------
+SET @bak_company_exists2 := (
+ SELECT COUNT(*) FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_erp_company_contact_bak'
+);
+SET @bak_dead_exists2 := (
+ SELECT COUNT(*) FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_contact_bak'
+);
+
+SELECT 'yz_backend_contact_company' AS `table_name`, COUNT(*) AS `row_count`
+FROM `yz_backend_contact_company`;
+
+SET @sql := IF(@bak_company_exists2 > 0,
+ 'SELECT ''yz_backend_erp_company_contact_bak'' AS `table_name`, COUNT(*) AS `row_count` FROM `yz_backend_erp_company_contact_bak`',
+ 'SELECT ''yz_backend_erp_company_contact_bak(不存在)'' AS `table_name`, 0 AS `row_count`');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @sql := IF(@bak_dead_exists2 > 0,
+ 'SELECT ''yz_tenant_crm_contact_bak'' AS `table_name`, COUNT(*) AS `row_count` FROM `yz_tenant_crm_contact_bak`',
+ 'SELECT ''yz_tenant_crm_contact_bak(不存在)'' AS `table_name`, 0 AS `row_count`');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
diff --git a/sql/alter_crm_pipeline_add_contact_position.sql b/sql/alter_crm_pipeline_add_contact_position.sql
new file mode 100644
index 0000000..5cbd55f
--- /dev/null
+++ b/sql/alter_crm_pipeline_add_contact_position.sql
@@ -0,0 +1,13 @@
+-- 为 CRM 业务管线(线索/商机/项目)增加「对接人职位」字段 contact_position。
+-- 若已执行过 yz_backend_crm_pipeline.sql(表已存在),执行本脚本即可;
+-- 若尚未建表,直接执行 yz_backend_crm_pipeline.sql 已包含该字段,无需本脚本。
+-- 脚本可重复执行:列已存在时 MySQL 报 duplicate column,忽略即可。
+
+ALTER TABLE `yz_tenant_crm_clue`
+ ADD COLUMN `contact_position` varchar(50) NOT NULL DEFAULT '' COMMENT '对接人职位' AFTER `contact_person`;
+
+ALTER TABLE `yz_tenant_crm_business`
+ ADD COLUMN `contact_position` varchar(50) NOT NULL DEFAULT '' COMMENT '对接人职位' AFTER `contact_person`;
+
+ALTER TABLE `yz_tenant_crm_project`
+ ADD COLUMN `contact_position` varchar(50) NOT NULL DEFAULT '' COMMENT '对接人职位' AFTER `contact_person`;
diff --git a/sql/yz_backend_crm_pipeline.sql b/sql/yz_backend_crm_pipeline.sql
new file mode 100644
index 0000000..d575641
--- /dev/null
+++ b/sql/yz_backend_crm_pipeline.sql
@@ -0,0 +1,233 @@
+-- =============================================================================
+-- CRM 业务管线:线索 → 商机 → 项目,回访贯穿全流程
+-- 对应租户端 backend 页面:/apps/crm/clue、/apps/crm/business、/apps/crm/project、/apps/crm/follow
+-- 说明:
+-- 1. 线索的「客户名称」暂不关联客户管理(线索阶段尚无价值),
+-- 线索转化为商机时才把客户名称落地为正式客户(yz_backend_erp_customer)。
+-- 2. 线索转化商机后会被锁定(locked=1),后续操作都在商机进行。
+-- 3. 商机转化项目后同样锁定(locked=1)。
+-- 4. 回访/跟进记录(yz_tenant_crm_follow)与附件、联系人、操作日志均为通用多态表,
+-- 通过 related_type(1线索/2商机/3项目) + related_id 关联。
+-- 可重复执行(DROP TABLE IF EXISTS)。
+-- =============================================================================
+
+SET NAMES utf8mb4;
+SET FOREIGN_KEY_CHECKS = 0;
+
+-- -----------------------------------------------------------------------------
+-- 线索表
+-- -----------------------------------------------------------------------------
+DROP TABLE IF EXISTS `yz_tenant_crm_clue`;
+CREATE TABLE `yz_tenant_crm_clue` (
+ `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
+ `tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
+ `clue_name` varchar(100) NOT NULL COMMENT '线索名称',
+ `customer_name` varchar(100) NOT NULL DEFAULT '' COMMENT '客户名称(线索阶段不关联客户管理)',
+ `source` varchar(50) NOT NULL DEFAULT '' COMMENT '客户来源(1官网咨询/2电话咨询/3朋友介绍/4展会/5广告投放/6陌生拜访/7其他)',
+ `owner_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '负责人用户ID',
+ `owner_user_name` varchar(128) NOT NULL DEFAULT '' COMMENT '负责人姓名',
+ `industry` varchar(50) NOT NULL DEFAULT '' COMMENT '客户行业',
+ `next_contact_time` datetime DEFAULT NULL COMMENT '下次联系时间',
+ `clue_level` varchar(20) NOT NULL DEFAULT '2' COMMENT '客户级别:1重点/2普通/3非优先',
+ `contact_person` varchar(50) NOT NULL DEFAULT '' COMMENT '客户对接人',
+ `contact_position` varchar(50) NOT NULL DEFAULT '' COMMENT '对接人职位',
+ `contact_phone` varchar(20) NOT NULL DEFAULT '' COMMENT '对接人手机',
+ `contact_wechat` varchar(64) NOT NULL DEFAULT '' COMMENT '对接人微信',
+ `contact_qq` varchar(20) NOT NULL DEFAULT '' COMMENT '对接人QQ',
+ `address` varchar(255) NOT NULL DEFAULT '' COMMENT '公司地址',
+ `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1跟进中/2已转化/3已关闭',
+ `locked` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否锁定:0否/1是(转化后锁定不可编辑)',
+ `business_id` bigint(20) DEFAULT NULL COMMENT '转化后的商机ID',
+ `convert_time` datetime DEFAULT NULL COMMENT '转化时间',
+ `remark` text 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_id` (`tenant_id`),
+ KEY `idx_clue_name` (`tenant_id`,`clue_name`),
+ KEY `idx_owner` (`tenant_id`,`owner_user_id`),
+ KEY `idx_status` (`tenant_id`,`status`),
+ KEY `idx_delete_time` (`delete_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户CRM线索表';
+
+-- -----------------------------------------------------------------------------
+-- 商机表
+-- -----------------------------------------------------------------------------
+DROP TABLE IF EXISTS `yz_tenant_crm_business`;
+CREATE TABLE `yz_tenant_crm_business` (
+ `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
+ `tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
+ `business_name` varchar(100) NOT NULL COMMENT '商机名称',
+ `clue_id` bigint(20) DEFAULT NULL COMMENT '来源线索ID',
+ `customer_id` bigint(20) DEFAULT NULL COMMENT '正式客户ID(yz_backend_erp_customer.id)',
+ `customer_name` varchar(100) NOT NULL DEFAULT '' COMMENT '客户名称',
+ `source` varchar(50) NOT NULL DEFAULT '' COMMENT '客户来源',
+ `owner_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '负责人用户ID',
+ `owner_user_name` varchar(128) NOT NULL DEFAULT '' COMMENT '负责人姓名',
+ `industry` varchar(50) NOT NULL DEFAULT '' COMMENT '客户行业',
+ `stage` varchar(20) NOT NULL DEFAULT '1' COMMENT '商机阶段:1初步接洽/2需求确认/3方案报价/4商务谈判/5赢单/6输单',
+ `amount` decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '预计金额(元)',
+ `expect_deal_date` date DEFAULT NULL COMMENT '预计成交日期',
+ `next_contact_time` datetime DEFAULT NULL COMMENT '下次联系时间',
+ `level` varchar(20) NOT NULL DEFAULT '2' COMMENT '商机级别:1重点/2普通/3非优先',
+ `contact_person` varchar(50) NOT NULL DEFAULT '' COMMENT '对接人',
+ `contact_position` varchar(50) NOT NULL DEFAULT '' COMMENT '对接人职位',
+ `contact_phone` varchar(20) NOT NULL DEFAULT '' COMMENT '对接人手机',
+ `contact_wechat` varchar(64) NOT NULL DEFAULT '' COMMENT '对接人微信',
+ `contact_qq` varchar(20) NOT NULL DEFAULT '' COMMENT '对接人QQ',
+ `address` varchar(255) NOT NULL DEFAULT '' COMMENT '客户地址',
+ `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1跟进中/2已转化项目/3已关闭',
+ `locked` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否锁定:0否/1是(转化项目后锁定不可编辑)',
+ `project_id` bigint(20) DEFAULT NULL COMMENT '转化后的项目ID',
+ `convert_time` datetime DEFAULT NULL COMMENT '转化时间',
+ `remark` text 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_id` (`tenant_id`),
+ KEY `idx_business_name` (`tenant_id`,`business_name`),
+ KEY `idx_clue` (`clue_id`),
+ KEY `idx_customer` (`customer_id`),
+ KEY `idx_owner` (`tenant_id`,`owner_user_id`),
+ KEY `idx_status` (`tenant_id`,`status`),
+ KEY `idx_delete_time` (`delete_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户CRM商机表';
+
+-- -----------------------------------------------------------------------------
+-- 项目表
+-- -----------------------------------------------------------------------------
+DROP TABLE IF EXISTS `yz_tenant_crm_project`;
+CREATE TABLE `yz_tenant_crm_project` (
+ `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
+ `tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
+ `project_name` varchar(100) NOT NULL COMMENT '项目名称',
+ `project_no` varchar(50) NOT NULL DEFAULT '' COMMENT '项目编号',
+ `business_id` bigint(20) DEFAULT NULL COMMENT '来源商机ID',
+ `customer_id` bigint(20) DEFAULT NULL COMMENT '客户ID',
+ `customer_name` varchar(100) NOT NULL DEFAULT '' COMMENT '客户名称',
+ `owner_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '负责人用户ID',
+ `owner_user_name` varchar(128) NOT NULL DEFAULT '' COMMENT '负责人姓名',
+ `industry` varchar(50) NOT NULL DEFAULT '' COMMENT '客户行业',
+ `amount` decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '项目金额(元)',
+ `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1未开始/2进行中/3已完成/4已暂停',
+ `start_date` date DEFAULT NULL COMMENT '开始日期',
+ `end_date` date DEFAULT NULL COMMENT '结束日期',
+ `contact_person` varchar(50) NOT NULL DEFAULT '' COMMENT '对接人',
+ `contact_position` varchar(50) NOT NULL DEFAULT '' COMMENT '对接人职位',
+ `contact_phone` varchar(20) NOT NULL DEFAULT '' COMMENT '对接人手机',
+ `address` varchar(255) NOT NULL DEFAULT '' COMMENT '项目地址',
+ `remark` text 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_id` (`tenant_id`),
+ KEY `idx_project_name` (`tenant_id`,`project_name`),
+ KEY `idx_business` (`business_id`),
+ KEY `idx_owner` (`tenant_id`,`owner_user_id`),
+ KEY `idx_status` (`tenant_id`,`status`),
+ KEY `idx_delete_time` (`delete_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户CRM项目表';
+
+-- -----------------------------------------------------------------------------
+-- 回访/跟进记录表(贯穿线索、商机、项目)
+-- -----------------------------------------------------------------------------
+DROP TABLE IF EXISTS `yz_tenant_crm_follow`;
+CREATE TABLE `yz_tenant_crm_follow` (
+ `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
+ `tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
+ `related_type` tinyint(4) NOT NULL COMMENT '关联类型:1线索/2商机/3项目',
+ `related_id` bigint(20) NOT NULL COMMENT '关联记录ID',
+ `related_name` varchar(100) NOT NULL DEFAULT '' COMMENT '关联对象名称(冗余,便于回访列表展示)',
+ `follow_type` varchar(20) NOT NULL DEFAULT '1' COMMENT '回访方式:1电话/2微信/3上门/4邮件/5其他',
+ `follow_time` datetime DEFAULT NULL COMMENT '回访时间',
+ `content` text COMMENT '回访内容',
+ `next_contact_time` datetime DEFAULT NULL COMMENT '下次联系时间',
+ `owner_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '回访人用户ID',
+ `owner_user_name` varchar(128) NOT NULL DEFAULT '' COMMENT '回访人姓名',
+ `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_id` (`tenant_id`),
+ KEY `idx_related` (`tenant_id`,`related_type`,`related_id`),
+ KEY `idx_follow_time` (`tenant_id`,`follow_time`),
+ KEY `idx_delete_time` (`delete_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户CRM回访/跟进记录表';
+
+-- -----------------------------------------------------------------------------
+-- 附件表(多态:线索/商机/项目的资料)
+-- -----------------------------------------------------------------------------
+DROP TABLE IF EXISTS `yz_tenant_crm_attach`;
+CREATE TABLE `yz_tenant_crm_attach` (
+ `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
+ `tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
+ `related_type` tinyint(4) NOT NULL COMMENT '关联类型:1线索/2商机/3项目',
+ `related_id` bigint(20) NOT NULL COMMENT '关联记录ID',
+ `file_id` bigint(20) DEFAULT NULL COMMENT '文件ID(yz_system_file.id,可空)',
+ `file_name` varchar(255) NOT NULL DEFAULT '' COMMENT '文件名',
+ `file_url` varchar(500) NOT NULL DEFAULT '' COMMENT '文件访问地址',
+ `file_size` bigint(20) NOT NULL DEFAULT '0' COMMENT '文件大小(字节)',
+ `uploader_id` varchar(64) NOT NULL DEFAULT '' COMMENT '上传人用户ID',
+ `uploader_name` varchar(128) NOT NULL DEFAULT '' COMMENT '上传人姓名',
+ `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_id` (`tenant_id`),
+ KEY `idx_related` (`tenant_id`,`related_type`,`related_id`),
+ KEY `idx_delete_time` (`delete_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户CRM附件表';
+
+-- -----------------------------------------------------------------------------
+-- 联系人表(多态:线索/商机/项目的对接人列表)
+-- -----------------------------------------------------------------------------
+DROP TABLE IF EXISTS `yz_tenant_crm_entity_contact`;
+CREATE TABLE `yz_tenant_crm_entity_contact` (
+ `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
+ `tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
+ `related_type` tinyint(4) NOT NULL COMMENT '关联类型:1线索/2商机/3项目',
+ `related_id` bigint(20) NOT NULL COMMENT '关联记录ID',
+ `contact_name` varchar(50) NOT NULL COMMENT '联系人姓名',
+ `position` varchar(50) NOT NULL DEFAULT '' COMMENT '职位',
+ `mobile` varchar(20) NOT NULL DEFAULT '' COMMENT '手机号',
+ `wechat` varchar(64) NOT NULL DEFAULT '' COMMENT '微信',
+ `qq` varchar(20) NOT NULL DEFAULT '' COMMENT 'QQ',
+ `email` varchar(100) NOT NULL DEFAULT '' COMMENT '邮箱',
+ `is_primary` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否主联系人:0否/1是',
+ `remark` varchar(500) NOT NULL DEFAULT '' COMMENT '备注',
+ `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_id` (`tenant_id`),
+ KEY `idx_related` (`tenant_id`,`related_type`,`related_id`),
+ KEY `idx_delete_time` (`delete_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户CRM联系人表(线索/商机/项目)';
+
+-- -----------------------------------------------------------------------------
+-- 操作日志表(多态:记录线索/商机/项目的关键操作,用于详情页操作日志 Tab)
+-- -----------------------------------------------------------------------------
+DROP TABLE IF EXISTS `yz_tenant_crm_operate_log`;
+CREATE TABLE `yz_tenant_crm_operate_log` (
+ `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
+ `tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
+ `related_type` tinyint(4) NOT NULL COMMENT '关联类型:1线索/2商机/3项目',
+ `related_id` bigint(20) NOT NULL COMMENT '关联记录ID',
+ `action` varchar(50) NOT NULL DEFAULT '' COMMENT '操作动作:create/update/convert/delete 等',
+ `content` varchar(500) NOT NULL DEFAULT '' COMMENT '操作描述',
+ `operator_id` varchar(64) NOT NULL DEFAULT '' COMMENT '操作人用户ID',
+ `operator_name` varchar(128) NOT NULL DEFAULT '' COMMENT '操作人姓名',
+ `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ PRIMARY KEY (`id`),
+ KEY `idx_tenant_id` (`tenant_id`),
+ KEY `idx_related` (`tenant_id`,`related_type`,`related_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户CRM操作日志表';
+
+SET FOREIGN_KEY_CHECKS = 1;
+