384 lines
12 KiB
Go
384 lines
12 KiB
Go
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]
|
||
}
|
||
}
|
||
}
|
||
|
||
|