Files
yunzerwebsiteallinone/go/controllers/backend_crm_pipeline_common.go
T

384 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
// 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
}
// 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]
}
}
}