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

706 lines
21 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 (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"server/models"
"server/pkg/jwtutil"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
)
// BackendCrmContactController CRM 联系人管理(客户 / 供应商的对接人)
//
// 数据复用已有的「公司联系人」表 models.ErpCompanyContact:
// - related_type(1=客户,2=供应商) <-> company_type(customer/supplier)
// - related_id <-> company_id
// - contact_name <-> name
// - mobile(手机号) <-> mobiles(JSON数组,取第一个)
// - phone(座机) <-> phone
// - wechat / qq / dingtalk / home_address / status <-> 同名字段
//
// 这样 CRM 联系人与 ERP 公司联系人是同一份数据,不会分裂。
type BackendCrmContactController struct {
beego.Controller
}
// crmContactDTO 返回给前端的联系人结构
type crmContactDTO struct {
ID uint64 `json:"id"`
RelatedType int `json:"related_type"`
RelatedID uint64 `json:"related_id"`
RelatedName string `json:"related_name"`
ContactName string `json:"contact_name"`
Gender int8 `json:"gender"`
Mobile string `json:"mobile"` // 手机号(mobiles 数组第一个)
Mobiles string `json:"mobiles"` // 手机号原始 JSON 数组
Phone string `json:"phone"` // 座机
Email string `json:"email"`
Wechat string `json:"wechat"` // 微信
QQ string `json:"qq"` // QQ
Dingtalk string `json:"dingtalk"` // 钉钉
Department string `json:"department"`
Position string `json:"position"`
HomeAddress string `json:"home_address"` // 家庭住址
IsPrimary int8 `json:"is_primary"`
Remark string `json:"remark"`
Status int8 `json:"status"` // 1-在职 0-离职
CreateTime time.Time `json:"create_time"`
UpdateTime time.Time `json:"update_time"`
}
// buildMobiles 将单个手机号转为 mobiles 的 JSON 数组字符串
func buildMobiles(mobile string) string {
mobile = strings.TrimSpace(mobile)
if mobile == "" {
return ""
}
arr := []string{mobile}
b, err := json.Marshal(arr)
if err != nil {
return ""
}
return string(b)
}
// buildMobilesFromList 将多个手机号去空、去重后转为 mobiles 的 JSON 数组字符串(最多 5 个,与 ERP 通讯录一致)
func buildMobilesFromList(mobiles []string) string {
cleaned := make([]string, 0, len(mobiles))
seen := map[string]bool{}
for _, m := range mobiles {
m = strings.TrimSpace(m)
if m == "" || seen[m] {
continue
}
seen[m] = true
cleaned = append(cleaned, m)
if len(cleaned) >= 5 {
break
}
}
if len(cleaned) == 0 {
return ""
}
b, err := json.Marshal(cleaned)
if err != nil {
return ""
}
return string(b)
}
// normalizeMobiles 归一请求中的手机号:
// 优先取 mobiles(数组,CRM 联系人支持登记多个手机号,如一人多号),
// 缺省时回退单个 mobile(兼容线索转化 / 合同签约人自动建档等旧调用方)。
func normalizeMobiles(mobiles []string, mobile string) string {
if len(mobiles) == 0 {
return buildMobiles(mobile)
}
return buildMobilesFromList(mobiles)
}
// splitMobiles 把手机号文本(JSON 数组 / 逗号等分隔符拼接)拆成号码列表(去空)
func splitMobiles(mobiles string) []string {
mobiles = strings.TrimSpace(mobiles)
if mobiles == "" {
return nil
}
var arr []string
if err := json.Unmarshal([]byte(mobiles), &arr); err == nil {
out := make([]string, 0, len(arr))
for _, m := range arr {
if m = strings.TrimSpace(m); m != "" {
out = append(out, m)
}
}
return out
}
parts := strings.FieldsFunc(mobiles, func(r rune) bool {
switch r {
case ',', ',', '、', ';', ';', ' ', '/', '|':
return true
}
return false
})
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// mobilesFromPayload 解析请求体中的手机号字段,兼容多种入参形式:
// - JSON 数组:["138…","139…"](新前端)
// - JSON 字符串:'["138…"]' 或 '138…,139…'(旧调用方)
// - 缺省:回退单个 mobile / phone 字段
//
// 统一返回 mobiles 的 JSON 数组字符串(去空去重,最多 5 个)。
func mobilesFromPayload(raw json.RawMessage, fallback string) string {
if len(raw) > 0 {
text := strings.TrimSpace(string(raw))
if text != "" && text != "null" {
var arr []string
if err := json.Unmarshal(raw, &arr); err == nil {
return buildMobilesFromList(arr)
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
if strings.TrimSpace(s) == "" {
return ""
}
return buildMobilesFromList(splitMobiles(s))
}
}
}
return buildMobiles(fallback)
}
// firstMobile 从 mobiles(JSON 数组或普通字符串)中取出第一个手机号
func firstMobile(mobiles string) string {
mobiles = strings.TrimSpace(mobiles)
if mobiles == "" {
return ""
}
var arr []string
if err := json.Unmarshal([]byte(mobiles), &arr); err == nil {
for _, m := range arr {
if strings.TrimSpace(m) != "" {
return strings.TrimSpace(m)
}
}
return ""
}
// 非 JSON,按原样返回
return mobiles
}
func crmContactCompanyType(relatedType int) string {
if relatedType == 2 {
return "supplier"
}
return "customer"
}
func crmContactRelatedType(companyType string) int {
if companyType == "supplier" {
return 2
}
return 1
}
// crmContactAtoi 将查询参数安全转为 int,非法值返回 0
func crmContactAtoi(s string) int {
n, _ := strconv.Atoi(strings.TrimSpace(s))
return n
}
func (c *BackendCrmContactController) contactClaims() (*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" {
return nil, fmt.Errorf("无权访问")
}
return claims, nil
}
func (c *BackendCrmContactController) crmContactJsonErr(httpStatus, bizCode int, msg string) {
c.Ctx.Output.SetStatus(httpStatus)
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
_ = c.ServeJSON()
}
func (c *BackendCrmContactController) crmContactOk(data interface{}) {
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data}
_ = c.ServeJSON()
}
// toCrmContactDTO 模型转 DTO,names 为关联对象名称映射(key: "customer:123")
func toCrmContactDTO(row models.ErpCompanyContact, names map[string]string) crmContactDTO {
relatedType := crmContactRelatedType(row.CompanyType)
key := fmt.Sprintf("%s:%d", row.CompanyType, row.CompanyID)
return crmContactDTO{
ID: row.ID,
RelatedType: relatedType,
RelatedID: row.CompanyID,
RelatedName: names[key],
ContactName: row.Name,
Gender: row.Gender,
Mobile: firstMobile(row.Mobiles),
Mobiles: row.Mobiles,
Phone: row.Phone,
Email: row.Email,
Wechat: row.Wechat,
QQ: row.QQ,
Dingtalk: row.Dingtalk,
Department: row.Department,
Position: row.Position,
HomeAddress: row.HomeAddress,
IsPrimary: row.IsPrimary,
Remark: row.Remark,
Status: row.Status,
CreateTime: row.CreateTime,
UpdateTime: row.UpdateTime,
}
}
// loadRelatedNames 批量查询关联的客户/供应商名称,避免逐条查询
func loadRelatedNames(tenantID string, rows []models.ErpCompanyContact) map[string]string {
names := map[string]string{}
if len(rows) == 0 || models.Orm == nil {
return names
}
customerIDs := make([]uint64, 0)
supplierIDs := make([]uint64, 0)
for _, r := range rows {
if r.CompanyType == "supplier" {
supplierIDs = append(supplierIDs, r.CompanyID)
} else {
customerIDs = append(customerIDs, r.CompanyID)
}
}
if len(customerIDs) > 0 {
var list []models.TenantCrmCustomer
if _, err := models.Orm.QueryTable(new(models.TenantCrmCustomer)).
Filter("tenant_id", tenantID).
Filter("id__in", customerIDs).
All(&list); err == nil {
for _, r := range list {
names[fmt.Sprintf("customer:%d", r.ID)] = r.CustomerName
}
}
}
if len(supplierIDs) > 0 {
var list []models.TenantCrmSupplier
if _, err := models.Orm.QueryTable(new(models.TenantCrmSupplier)).
Filter("tenant_id", tenantID).
Filter("id__in", supplierIDs).
All(&list); err == nil {
for _, r := range list {
names[fmt.Sprintf("supplier:%d", r.ID)] = r.SupplierName
}
}
}
return names
}
// clearOtherPrimary 将同一公司下的其他联系人取消主联系人,保证只有一个主联系人
func clearOtherPrimary(tenantID, companyType string, companyID, excludeID uint64) {
if models.Orm == nil || companyID == 0 {
return
}
qs := models.Orm.QueryTable(new(models.ErpCompanyContact)).
Filter("tenant_id", tenantID).
Filter("company_type", companyType).
Filter("company_id", companyID).
Filter("delete_time__isnull", true).
Exclude("id", excludeID)
_, _ = qs.Update(map[string]interface{}{"is_primary": 0})
}
// isMobileNumber 粗略识别手机号(11 位、1 开头,忽略空格 / 横线等分隔符);
// 非手机号按座机写入 phone 字段,保证联系人资料里手机号 / 座机分列正确。
func isMobileNumber(phone string) bool {
digits := strings.Map(func(r rune) rune {
if r >= '0' && r <= '9' {
return r
}
return -1
}, phone)
return len(digits) == 11 && digits[0] == '1'
}
// crmContactCompanyExists 关联企业(客户 / 供应商)在当前租户下是否存在且未删除
func crmContactCompanyExists(tenantID, companyType string, companyID uint64) bool {
if models.Orm == nil || companyID == 0 {
return false
}
if companyType == "supplier" {
cnt, err := models.Orm.QueryTable(new(models.TenantCrmSupplier)).
Filter("id", companyID).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).Count()
return err == nil && cnt > 0
}
cnt, err := models.Orm.QueryTable(new(models.TenantCrmCustomer)).
Filter("id", companyID).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).Count()
return err == nil && cnt > 0
}
// contactPhoneMatched 联系人是否已登记该电话(手机号数组或座机任一命中)
func contactPhoneMatched(row models.ErpCompanyContact, phone string) bool {
if strings.TrimSpace(row.Phone) == phone {
return true
}
var mobiles []string
if err := json.Unmarshal([]byte(strings.TrimSpace(row.Mobiles)), &mobiles); err == nil {
for _, m := range mobiles {
if strings.TrimSpace(m) == phone {
return true
}
}
}
return false
}
// crmEnsureCompanyContact 确保企业通讯录(联系人)中已有该对接人:
// - 同名或同电话已存在时不再重复建档,仅在原联系人缺电话时补全;
// - 不存在时按「姓名 + 电话」建档,其余资料留空,后续可在联系人管理中补全;
// - 该企业还没有任何联系人时,默认把首个联系人设为主联系人。
func crmEnsureCompanyContact(tenantID, companyType string, companyID uint64, name, phone string) {
if models.Orm == nil || companyID == 0 {
return
}
name = strings.TrimSpace(name)
phone = strings.TrimSpace(phone)
if name == "" {
return
}
var rows []models.ErpCompanyContact
if _, err := models.Orm.QueryTable(new(models.ErpCompanyContact)).
Filter("tenant_id", tenantID).
Filter("company_type", companyType).
Filter("company_id", companyID).
Filter("delete_time__isnull", true).
All(&rows); err != nil && err != orm.ErrNoRows {
return
}
for i := range rows {
row := rows[i]
sameName := strings.EqualFold(strings.TrimSpace(row.Name), name)
samePhone := phone != "" && contactPhoneMatched(row, phone)
if !sameName && !samePhone {
continue
}
// 已存在:仅在原记录没有电话时补全,不覆盖其他已填资料
if phone != "" && strings.TrimSpace(row.Mobiles) == "" && strings.TrimSpace(row.Phone) == "" {
up := map[string]interface{}{"update_time": time.Now()}
if isMobileNumber(phone) {
up["mobiles"] = buildMobiles(phone)
} else {
up["phone"] = phone
}
_, _ = models.Orm.QueryTable(new(models.ErpCompanyContact)).
Filter("id", row.ID).Update(up)
}
return
}
now := time.Now()
contact := models.ErpCompanyContact{
TenantID: tenantID,
CompanyType: companyType,
CompanyID: companyID,
Name: name,
Status: 1,
Remark: "由合同签约信息自动写入,可在联系人中补全其他资料",
CreateTime: now,
UpdateTime: now,
}
if len(rows) == 0 {
contact.IsPrimary = 1
}
if phone != "" {
if isMobileNumber(phone) {
contact.Mobiles = buildMobiles(phone)
} else {
contact.Phone = phone
}
}
_, _ = models.Orm.Insert(&contact)
}
// List GET /backend/crm/contact/list?related_type=1&related_id=7&keyword=&is_primary=
func (c *BackendCrmContactController) List() {
claims, err := c.contactClaims()
if err != nil {
c.crmContactJsonErr(401, 401, err.Error())
return
}
page, _ := c.GetInt("page", 1)
pageSize, _ := c.GetInt("pageSize", 20)
keyword := strings.TrimSpace(c.GetString("keyword"))
relatedTypeStr := strings.TrimSpace(c.GetString("related_type"))
relatedIDStr := strings.TrimSpace(c.GetString("related_id"))
primaryStr := strings.TrimSpace(c.GetString("is_primary"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
tenantID := fmt.Sprintf("%d", claims.TenantId)
cond := orm.NewCondition().
And("tenant_id", tenantID).
And("delete_time__isnull", true)
if relatedTypeStr != "" {
cond = cond.And("company_type", crmContactCompanyType(crmContactAtoi(relatedTypeStr)))
}
if relatedIDStr != "" && relatedIDStr != "0" {
cond = cond.And("company_id", relatedIDStr)
}
if primaryStr != "" {
cond = cond.And("is_primary", crmContactAtoi(primaryStr))
}
if keyword != "" {
kw := orm.NewCondition().
Or("name__contains", keyword).
Or("mobiles__contains", keyword).
Or("phone__contains", keyword).
Or("email__contains", keyword)
cond = cond.AndCond(kw)
}
qs := models.Orm.QueryTable(new(models.ErpCompanyContact)).SetCond(cond)
total, _ := qs.Count()
var rows []models.ErpCompanyContact
if total > 0 {
_, _ = qs.OrderBy("-is_primary", "-update_time").
Offset((page - 1) * pageSize).
Limit(pageSize).
All(&rows)
}
names := loadRelatedNames(tenantID, rows)
list := make([]crmContactDTO, 0, len(rows))
for _, r := range rows {
list = append(list, toCrmContactDTO(r, names))
}
c.crmContactOk(map[string]interface{}{
"list": list,
"total": total,
"page": page,
"pageSize": pageSize,
})
}
// Add POST /backend/crm/contact/add
func (c *BackendCrmContactController) Add() {
claims, err := c.contactClaims()
if err != nil {
c.crmContactJsonErr(401, 401, err.Error())
return
}
var p struct {
RelatedType int `json:"related_type"`
RelatedID uint64 `json:"related_id"`
ContactName string `json:"contact_name"`
Gender int8 `json:"gender"`
Mobile string `json:"mobile"`
// 手机号可登记多个(有的人有多个号码):优先使用,缺省时回退单个 mobile
Mobiles []string `json:"mobiles"`
Phone string `json:"phone"`
Email string `json:"email"`
Wechat string `json:"wechat"`
QQ string `json:"qq"`
Dingtalk string `json:"dingtalk"`
Department string `json:"department"`
Position string `json:"position"`
HomeAddress string `json:"home_address"`
IsPrimary int8 `json:"is_primary"`
Status int8 `json:"status"`
Remark string `json:"remark"`
}
raw, _ := io.ReadAll(c.Ctx.Request.Body)
if err := json.Unmarshal(raw, &p); err != nil {
c.crmContactJsonErr(400, 400, "参数错误")
return
}
if strings.TrimSpace(p.ContactName) == "" {
c.crmContactJsonErr(400, 400, "联系人姓名不能为空")
return
}
if p.RelatedID == 0 {
c.crmContactJsonErr(400, 400, "请选择关联对象")
return
}
companyType := crmContactCompanyType(p.RelatedType)
now := time.Now()
contact := models.ErpCompanyContact{
TenantID: fmt.Sprintf("%d", claims.TenantId),
CompanyType: companyType,
CompanyID: p.RelatedID,
Name: strings.TrimSpace(p.ContactName),
Gender: p.Gender,
Phone: strings.TrimSpace(p.Phone), // 座机
Mobiles: normalizeMobiles(p.Mobiles, p.Mobile), // 手机号(支持多个,JSON数组)
Email: strings.TrimSpace(p.Email),
Wechat: strings.TrimSpace(p.Wechat),
QQ: strings.TrimSpace(p.QQ),
Dingtalk: strings.TrimSpace(p.Dingtalk),
Department: strings.TrimSpace(p.Department),
Position: strings.TrimSpace(p.Position),
HomeAddress: strings.TrimSpace(p.HomeAddress),
IsPrimary: p.IsPrimary,
Status: 1,
Remark: p.Remark,
CreateTime: now,
UpdateTime: now,
}
// 新增联系人默认在职
if p.Status == 0 {
contact.Status = 1
} else {
contact.Status = p.Status
}
id, err := models.Orm.Insert(&contact)
if err != nil {
c.crmContactJsonErr(500, 500, "新增失败: "+err.Error())
return
}
if p.IsPrimary == 1 {
clearOtherPrimary(fmt.Sprintf("%d", claims.TenantId), companyType, p.RelatedID, uint64(id))
}
c.crmContactOk(map[string]interface{}{"id": id})
}
// Edit POST /backend/crm/contact/edit
func (c *BackendCrmContactController) Edit() {
claims, err := c.contactClaims()
if err != nil {
c.crmContactJsonErr(401, 401, err.Error())
return
}
var p struct {
ID uint64 `json:"id"`
RelatedType int `json:"related_type"`
RelatedID uint64 `json:"related_id"`
ContactName string `json:"contact_name"`
Gender int8 `json:"gender"`
Mobile string `json:"mobile"`
// 手机号可登记多个(有的人有多个号码):优先使用,缺省时回退单个 mobile
Mobiles []string `json:"mobiles"`
Phone string `json:"phone"`
Email string `json:"email"`
Wechat string `json:"wechat"`
QQ string `json:"qq"`
Dingtalk string `json:"dingtalk"`
Department string `json:"department"`
Position string `json:"position"`
HomeAddress string `json:"home_address"`
IsPrimary int8 `json:"is_primary"`
Status int8 `json:"status"`
Remark string `json:"remark"`
}
raw, _ := io.ReadAll(c.Ctx.Request.Body)
if err := json.Unmarshal(raw, &p); err != nil {
c.crmContactJsonErr(400, 400, "参数错误")
return
}
if p.ID == 0 {
c.crmContactJsonErr(400, 400, "缺少ID")
return
}
if strings.TrimSpace(p.ContactName) == "" {
c.crmContactJsonErr(400, 400, "联系人姓名不能为空")
return
}
tenantID := fmt.Sprintf("%d", claims.TenantId)
var contact models.ErpCompanyContact
if err := models.Orm.QueryTable(new(models.ErpCompanyContact)).
Filter("id", p.ID).
Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).
One(&contact); err != nil {
c.crmContactJsonErr(404, 404, "联系人未找到")
return
}
// 允许调整关联对象;未传则保持原值
companyType := contact.CompanyType
companyID := contact.CompanyID
if p.RelatedID > 0 {
companyType = crmContactCompanyType(p.RelatedType)
companyID = p.RelatedID
}
contact.CompanyType = companyType
contact.CompanyID = companyID
contact.Name = strings.TrimSpace(p.ContactName)
contact.Gender = p.Gender
contact.Phone = strings.TrimSpace(p.Phone) // 座机
contact.Mobiles = normalizeMobiles(p.Mobiles, p.Mobile) // 手机号(支持多个,JSON数组)
contact.Email = strings.TrimSpace(p.Email)
contact.Wechat = strings.TrimSpace(p.Wechat)
contact.QQ = strings.TrimSpace(p.QQ)
contact.Dingtalk = strings.TrimSpace(p.Dingtalk)
contact.Department = strings.TrimSpace(p.Department)
contact.Position = strings.TrimSpace(p.Position)
contact.HomeAddress = strings.TrimSpace(p.HomeAddress)
contact.IsPrimary = p.IsPrimary
contact.Status = p.Status
contact.Remark = p.Remark
contact.UpdateTime = time.Now()
if _, err := models.Orm.Update(&contact); err != nil {
c.crmContactJsonErr(500, 500, "更新失败: "+err.Error())
return
}
if p.IsPrimary == 1 {
clearOtherPrimary(tenantID, companyType, companyID, contact.ID)
}
c.crmContactOk(map[string]interface{}{"id": contact.ID})
}
// Delete POST /backend/crm/contact/delete 软删除
func (c *BackendCrmContactController) Delete() {
claims, err := c.contactClaims()
if err != nil {
c.crmContactJsonErr(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 {
c.crmContactJsonErr(400, 400, "参数错误")
return
}
if p.ID == 0 {
c.crmContactJsonErr(400, 400, "缺少ID")
return
}
now := time.Now()
num, err := models.Orm.QueryTable(new(models.ErpCompanyContact)).
Filter("id", p.ID).
Filter("tenant_id", fmt.Sprintf("%d", claims.TenantId)).
Filter("delete_time__isnull", true).
Update(map[string]interface{}{
"delete_time": now,
"update_time": now,
})
if err != nil {
c.crmContactJsonErr(500, 500, "删除失败: "+err.Error())
return
}
if num == 0 {
c.crmContactJsonErr(404, 404, "联系人未找到")
return
}
c.crmContactOk(nil)
}