package controllers import ( "encoding/json" "io" "sort" "strconv" "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")) includeSource := strings.TrimSpace(c.GetString("include_source")) == "1" // 详情页链路模式(include_source=1):按 线索→商机→项目 累积返回上游 + 自身的数据 if includeSource && relatedType != "" && relatedID != "" && relatedID != "0" { rid, _ := strconv.ParseUint(relatedID, 10, 64) rt, _ := strconv.Atoi(relatedType) refs := relatedChain(tenantID, int8(rt), rid) var merged []models.TenantCrmFollow for _, r := range refs { var rows []models.TenantCrmFollow sub := orm.NewCondition(). And("tenant_id", tenantID). And("delete_time__isnull", true). And("related_type", r.Type). And("related_id", r.ID) _, _ = models.Orm.QueryTable(new(models.TenantCrmFollow)).SetCond(sub).All(&rows) merged = append(merged, rows...) } sort.Slice(merged, func(i, j int) bool { ti, tj := merged[i].FollowTime, merged[j].FollowTime if ti != nil && tj != nil && !ti.Equal(*tj) { return ti.After(*tj) } if (ti == nil) != (tj == nil) { return ti != nil } return merged[i].ID > merged[j].ID }) pipelineOk(&c.Controller, map[string]interface{}{ "list": merged, "total": len(merged), "page": 1, "pageSize": len(merged), }) return } 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"` Stage int8 `json:"stage"` // 创建阶段:不传时默认取 related_type 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) // 创建阶段:默认取当前关联类型(线索/商机/项目) stage := p.Stage if stage != 1 && stage != 2 && stage != 3 { stage = p.RelatedType } row := models.TenantCrmFollow{ TenantID: tenantID, RelatedType: p.RelatedType, RelatedID: p.RelatedID, RelatedName: strings.TrimSpace(p.RelatedName), Stage: stage, FollowType: firstNonEmpty(p.FollowType, "1"), FollowTime: followTime, Content: p.Content, NextContactTime: nextContact, OwnerUserID: pipelineUID(claims), OwnerUserName: resolveUserName(claims), 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) // 回访阶段(所属阶段)允许修正:仅在传入合法阶段(1线索/2商机/3项目)时更新, // 保证"记录创建时所在阶段"可被纠正为沟通实际发生的阶段。 if p.Stage == 1 || p.Stage == 2 || p.Stage == 3 { row.Stage = p.Stage } 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")) includeSource := strings.TrimSpace(c.GetString("include_source")) == "1" tenantID := pipelineTenantID(claims) page, _ := strconv.Atoi(c.GetString("page")) pageSize, _ := strconv.Atoi(c.GetString("pageSize")) if page < 1 { page = 1 } if pageSize < 1 { pageSize = 20 } offset := (page - 1) * pageSize var list []models.TenantCrmAttach total := 0 if includeSource && relatedType != "" && relatedID != "" && relatedID != "0" { // 链路模式:线索 → 商机 → 项目 累积(跨表汇总后内存分页) rid, _ := strconv.ParseUint(relatedID, 10, 64) rt, _ := strconv.Atoi(relatedType) for _, r := range relatedChain(tenantID, int8(rt), rid) { var rows []models.TenantCrmAttach sub := orm.NewCondition(). And("tenant_id", tenantID). And("delete_time__isnull", true). And("related_type", r.Type). And("related_id", r.ID) _, _ = models.Orm.QueryTable(new(models.TenantCrmAttach)).SetCond(sub).All(&rows) list = append(list, rows...) } sort.Slice(list, func(i, j int) bool { return list[i].ID > list[j].ID }) total = len(list) start := offset if start > total { start = total } end := start + pageSize if end > total { end = total } list = list[start:end] } else { cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true) if relatedType != "" { cond = cond.And("related_type", relatedType) } if relatedID != "" { cond = cond.And("related_id", relatedID) } qs := models.Orm.QueryTable(new(models.TenantCrmAttach)).SetCond(cond) if cnt, err := qs.Count(); err == nil { total = int(cnt) } _, _ = qs.OrderBy("-id").Limit(pageSize, offset).All(&list) } pipelineOk(&c.Controller, map[string]interface{}{ "list": list, "total": total, "page": page, "pageSize": pageSize, }) } // 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: resolveUserName(claims), 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")) includeSource := strings.TrimSpace(c.GetString("include_source")) == "1" tenantID := pipelineTenantID(claims) var list []models.TenantCrmEntityContact if includeSource && relatedType != "" && relatedID != "" && relatedID != "0" { // 链路模式:线索 → 商机 → 项目 累积 rid, _ := strconv.ParseUint(relatedID, 10, 64) rt, _ := strconv.Atoi(relatedType) for _, r := range relatedChain(tenantID, int8(rt), rid) { var rows []models.TenantCrmEntityContact sub := orm.NewCondition(). And("tenant_id", tenantID). And("delete_time__isnull", true). And("related_type", r.Type). And("related_id", r.ID) _, _ = models.Orm.QueryTable(new(models.TenantCrmEntityContact)).SetCond(sub).All(&rows) list = append(list, rows...) } sort.Slice(list, func(i, j int) bool { if list[i].IsPrimary != list[j].IsPrimary { return list[i].IsPrimary > list[j].IsPrimary } return list[i].ID > list[j].ID }) } else { cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true) if relatedType != "" { cond = cond.And("related_type", relatedType) } if relatedID != "" { cond = cond.And("related_id", relatedID) } _, _ = 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"` // 手机号可登记多个(有的人有多个号码):优先使用,缺省时回退单个 mobile Mobiles []string `json:"mobiles"` 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) // 手机号支持多个:优先取 mobiles 数组,缺省回退单个 mobile;mobile 保留为第一个号码 mobilesJSON := normalizeMobiles(p.Mobiles, p.Mobile) row := models.TenantCrmEntityContact{ TenantID: tenantID, RelatedType: p.RelatedType, RelatedID: p.RelatedID, ContactName: strings.TrimSpace(p.ContactName), Position: strings.TrimSpace(p.Position), Mobile: firstMobile(mobilesJSON), Mobiles: mobilesJSON, 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.Mobiles = normalizeMobiles(p.Mobiles, p.Mobile) row.Mobile = firstMobile(row.Mobiles) 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)}) }