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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 }} + +
+
- - + 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 @@ + + + + + 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 @@ + + + + + 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; +