Files
yunzerwebsiteallinone/go/controllers/backend_crm_clue.go
T
2026-09-14 16:15:44 +08:00

545 lines
18 KiB
Go

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 ""
}