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" ) // 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"` Amount float64 `json:"amount"` // 预计金额 NextContactTime string `json:"next_contact_time"` ClueLevel string `json:"clue_level"` // 线索状态:1跟进中 / 2已转化 / 3已废弃(编辑时可调整;新增固定为跟进中) Status string `json:"status"` 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) // 数据范围:平台/租户管理员可见全部,其他用户仅本人(负责人或创建人) cond = crmApplyOwnerScope(cond, claims) 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 { // 列表排列:跟进中(1) → 已转化(2) → 已废弃(3),同状态下按新→旧。 // 状态值本身即为期望的展示顺序,按 status 升序即可(排序在数据库侧完成,翻页后整体顺序一致)。 _, _ = qs.OrderBy("status", "-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 } // 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据 if !canAccessCrmRecord(claims, clue.OwnerUserID, clue.CreateUserID) { pipelineErr(&c.Controller, 403, 403, "无权操作该线索数据") 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 = resolveUserName(claims) } 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), Amount: p.Amount, 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, } // 唯一性校验:同租户内不允许重复创建同名线索(重复数据请联系管理员转移跟进) if crmNameDuplicated(new(models.TenantCrmClue), clue.TenantID, "clue_name", clue.ClueName) { pipelineErr(&c.Controller, 409, 409, "该线索名称已存在(可能由其他同事创建),如需继续跟进请联系管理员转移") return } 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 !canAccessCrmRecord(claims, clue.OwnerUserID, clue.CreateUserID) { pipelineErr(&c.Controller, 403, 403, "无权操作该线索数据") 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.Amount = p.Amount 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 // 线索状态:编辑时可直接管理(1跟进中 / 2已转化 / 3已废弃)。 // 标为「已转化」时与转化流程保持一致:同时锁定(locked=1),列表中沉底且不能再编辑 / 转化; // 改回跟进中 / 已废弃则解除锁定(历史数据同样兜底)。 prevStatus := clue.Status if s := strings.TrimSpace(p.Status); s != "" { if v, err := strconv.Atoi(s); err == nil && v >= 1 && v <= 3 { clue.Status = int8(v) clue.Locked = 0 if clue.Status == 2 { clue.Locked = 1 } } } clue.UpdateTime = time.Now() if _, err := models.Orm.Update(&clue); err != nil { pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error()) return } if clue.Status != prevStatus { crmWriteLog(clue.TenantID, 1, clue.ID, "status", fmt.Sprintf("线索状态:%s → %s", clueStatusName(prevStatus), clueStatusName(clue.Status)), claims) } crmWriteLog(clue.TenantID, 1, clue.ID, "update", "更新线索:"+clue.ClueName, claims) pipelineOk(&c.Controller, map[string]interface{}{"id": clue.ID}) } // clueStatusName 线索状态文案(用于操作日志):1跟进中 / 2已转化 / 3已废弃 func clueStatusName(s int8) string { switch s { case 2: return "已转化" case 3: return "已废弃" default: return "跟进中" } } // 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 !canAccessCrmRecord(claims, clue.OwnerUserID, clue.CreateUserID) { pipelineErr(&c.Controller, 403, 403, "无权操作该线索数据") return } if clue.Locked == 1 || clue.Status == 2 { pipelineErr(&c.Controller, 400, 400, "线索已转化并锁定,不能删除") 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 !canAccessCrmRecord(claims, clue.OwnerUserID, clue.CreateUserID) { pipelineErr(&c.Controller, 403, 403, "无权操作该线索数据") 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 "" }