package controllers import ( "fmt" "strings" "time" "server/models" "server/pkg/jwtutil" "server/services" beego "github.com/beego/beego/v2/server/web" ) // resolveUserName 按租户 + 用户ID 解析用户显示名(优先真实姓名,退化到账号,最后退化到登录名)。 // 注意:JWT 里的 Username 是登录账号,直接存会导致前端"负责人"显示成账号。 func resolveUserName(claims *jwtutil.Claims) string { if claims == nil { return "" } tid := uint64(claims.TenantId) account, name := services.OperationLogUser(&tid, uint64(claims.UserID)) if strings.TrimSpace(name) != "" { return strings.TrimSpace(name) } if strings.TrimSpace(account) != "" { return strings.TrimSpace(account) } return claims.Username } // 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", // 带时区偏移的 RFC3339:前端编辑时回显的是后端 JSON 序列化的 time.Time // (如 2026-09-12T00:00:00+08:00),缺此布局会解析失败导致日期被写空。 time.RFC3339Nano, time.RFC3339, "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 = resolveUserName(claims) } 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) } // entityContactMobiles 过程库联系人的手机号集合(JSON 数组字符串): // 优先取新的 mobiles 字段,缺省时回退旧的单个 mobile。 func entityContactMobiles(src models.TenantCrmEntityContact) string { if s := strings.TrimSpace(src.Mobiles); s != "" { return s } return buildMobiles(src.Mobile) } // mobilesMatched 判断两组手机号是否有交集(JSON 数组或分隔符字符串) func mobilesMatched(a, b string) bool { for _, x := range splitMobiles(a) { if x == "" { continue } for _, y := range splitMobiles(b) { if x == y { return true } } } return false } // 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) == "" { if m := entityContactMobiles(src); m != "" { dst.Mobiles = m 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 } mobiles := entityContactMobiles(src) mobile := firstMobile(mobiles) // 本次待同步集合内去重(同名同手机只处理一次) 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 == "" || mobilesMatched(mobiles, 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: mobiles, 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 } // relatedRef 关联对象引用(related_type + related_id) type relatedRef struct { Type int8 ID uint64 } // relatedChain 返回详情页需要展示的关联链路(上游 + 自身),顺序:线索 → 商机 → 项目。 // 线索 → [线索] // 商机 → [线索, 商机] // 项目 → [线索, 商机, 项目] // 过程数据(联系人 / 跟进记录 / 附件)只存在创建时的对象上、不做搬迁, // 由上层按链路累积查询,从而在商机里能看到线索阶段的数据,在项目里能看到全部阶段的数据。 func relatedChain(tenantID string, relatedType int8, relatedID uint64) []relatedRef { if models.Orm == nil || relatedID == 0 { return []relatedRef{{Type: relatedType, ID: relatedID}} } refs := make([]relatedRef, 0, 3) switch relatedType { case 2: // 商机:来源线索 + 自身 var biz models.TenantCrmBusiness if err := models.Orm.QueryTable(new(models.TenantCrmBusiness)). Filter("id", relatedID).Filter("tenant_id", tenantID). Filter("delete_time__isnull", true).One(&biz); err == nil { if biz.ClueID != nil && *biz.ClueID > 0 { refs = append(refs, relatedRef{Type: 1, ID: *biz.ClueID}) } } refs = append(refs, relatedRef{Type: 2, ID: relatedID}) case 3: // 项目:来源商机 + 商机的来源线索 + 自身 var proj models.TenantCrmProject if err := models.Orm.QueryTable(new(models.TenantCrmProject)). Filter("id", relatedID).Filter("tenant_id", tenantID). Filter("delete_time__isnull", true).One(&proj); err == nil { if proj.BusinessID != nil && *proj.BusinessID > 0 { var biz models.TenantCrmBusiness if err2 := models.Orm.QueryTable(new(models.TenantCrmBusiness)). Filter("id", *proj.BusinessID).Filter("tenant_id", tenantID). Filter("delete_time__isnull", true).One(&biz); err2 == nil { if biz.ClueID != nil && *biz.ClueID > 0 { refs = append(refs, relatedRef{Type: 1, ID: *biz.ClueID}) } refs = append(refs, relatedRef{Type: 2, ID: biz.ID}) } } } refs = append(refs, relatedRef{Type: 3, ID: relatedID}) default: // 线索:仅自身 refs = append(refs, relatedRef{Type: relatedType, ID: relatedID}) } return refs } // attachBusinessName 批量填充项目的「来源商机名称」(非表字段,仅用于展示)。 func attachBusinessName(tenantID string, list []models.TenantCrmProject) { ids := make([]uint64, 0, len(list)) seen := map[uint64]bool{} for _, p := range list { if p.BusinessID != nil && *p.BusinessID > 0 && !seen[*p.BusinessID] { seen[*p.BusinessID] = true ids = append(ids, *p.BusinessID) } } if len(ids) == 0 { return } var rows []models.TenantCrmBusiness _, _ = models.Orm.QueryTable(new(models.TenantCrmBusiness)). Filter("tenant_id", tenantID). Filter("id__in", ids). Filter("delete_time__isnull", true). All(&rows) nameMap := make(map[uint64]string, len(rows)) for _, r := range rows { nameMap[r.ID] = r.BusinessName } for i := range list { if list[i].BusinessID != nil { list[i].BusinessName = nameMap[*list[i].BusinessID] } } } // attachClueName 批量填充商机的「来源线索名称」(非表字段,仅用于展示)。 func attachClueName(tenantID string, list []models.TenantCrmBusiness) { ids := make([]uint64, 0, len(list)) seen := map[uint64]bool{} for _, b := range list { if b.ClueID != nil && *b.ClueID > 0 && !seen[*b.ClueID] { seen[*b.ClueID] = true ids = append(ids, *b.ClueID) } } if len(ids) == 0 { return } var rows []models.TenantCrmClue _, _ = models.Orm.QueryTable(new(models.TenantCrmClue)). Filter("tenant_id", tenantID). Filter("id__in", ids). Filter("delete_time__isnull", true). All(&rows) nameMap := make(map[uint64]string, len(rows)) for _, r := range rows { nameMap[r.ID] = r.ClueName } for i := range list { if list[i].ClueID != nil { list[i].ClueName = nameMap[*list[i].ClueID] } } }