更新各项内容
This commit is contained in:
@@ -179,11 +179,12 @@ type mcpToolSummary struct {
|
||||
|
||||
const maxToolRounds = 6
|
||||
|
||||
// aiUsage 一次 AI 调用的 token 用量
|
||||
// aiUsage 一次 AI 调用的 token 用量与真实模型
|
||||
type aiUsage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
Model string `json:"model,omitempty"` // 接口实际返回的使用模型(如网关把 auto 解析后的真实模型)
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
}
|
||||
|
||||
// add 累加用量
|
||||
@@ -201,6 +202,21 @@ func (u aiUsage) total() int {
|
||||
return u.PromptTokens + u.CompletionTokens
|
||||
}
|
||||
|
||||
// usedMcpTool 单次调用中实际使用的 MCP 工具(含所属服务器)
|
||||
type usedMcpTool struct {
|
||||
ServerID uint64 `json:"server_id"`
|
||||
ServerName string `json:"server_name"`
|
||||
ToolName string `json:"tool_name"`
|
||||
}
|
||||
|
||||
// aiToolUsage 一次 AI 工具循环调用的元信息:用了什么模型、调用了哪些 MCP 工具
|
||||
type aiToolUsage struct {
|
||||
Model string `json:"model"` // 实际使用的模型
|
||||
Rounds int `json:"rounds"` // 工具循环轮数
|
||||
Tools []usedMcpTool `json:"tools"` // 实际被调用的 MCP 工具(去重)
|
||||
ToolCount int `json:"tool_count"` // 工具调用种类数
|
||||
}
|
||||
|
||||
// sanitizeToolName 工具名规范化为 OpenAI 允许的字符集
|
||||
func sanitizeToolName(s string) string {
|
||||
var sb strings.Builder
|
||||
@@ -346,24 +362,56 @@ func buildMcpAssistantMessage(text string, pending []pendingToolCall) openaiMess
|
||||
return msg
|
||||
}
|
||||
|
||||
// runToolLoop 运行带已启用 MCP 工具的完整对话循环(非流式),返回最终文本。
|
||||
// runToolLoop 运行带已启用 MCP 工具的完整对话循环(非流式),返回最终文本与调用元信息。
|
||||
// 供聊天非流式接口与智能生成等模块复用。
|
||||
func runToolLoop(claims *jwtutil.Claims, provider models.BackendAiProvider, model string, systemPrompt string, messages []openaiMessage) (string, error) {
|
||||
func runToolLoop(claims *jwtutil.Claims, provider models.BackendAiProvider, model string, systemPrompt string, messages []openaiMessage) (string, *aiToolUsage, error) {
|
||||
// 智能生成等内部调用:默认注入全部已启用的 MCP 工具
|
||||
llmTools, refMap, _, _ := collectMcpTools(claims, true, nil)
|
||||
usage := &aiToolUsage{Model: model}
|
||||
usedSet := make(map[string]bool)
|
||||
addUsed := func(key string) {
|
||||
ref, ok := refMap[key]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id := fmt.Sprintf("%d:%s", ref.ServerID, ref.ToolName)
|
||||
if usedSet[id] {
|
||||
return
|
||||
}
|
||||
usedSet[id] = true
|
||||
usage.Tools = append(usage.Tools, usedMcpTool{
|
||||
ServerID: ref.ServerID,
|
||||
ServerName: ref.ServerName,
|
||||
ToolName: ref.ToolName,
|
||||
})
|
||||
}
|
||||
|
||||
current := messages
|
||||
rounds := 0
|
||||
for {
|
||||
rounds++
|
||||
text, pending, _, err := callAITools(provider, model, systemPrompt, current, llmTools)
|
||||
text, pending, roundUsage, err := callAITools(provider, model, systemPrompt, current, llmTools)
|
||||
// 用接口实际返回的真实模型覆盖占位符(如 auto),若网关未回传则保留请求模型
|
||||
if roundUsage.Model != "" {
|
||||
usage.Model = roundUsage.Model
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
usage.Rounds = rounds
|
||||
usage.ToolCount = len(usage.Tools)
|
||||
return "", usage, err
|
||||
}
|
||||
for _, pc := range pending {
|
||||
addUsed(pc.Name)
|
||||
}
|
||||
if len(pending) == 0 {
|
||||
return text, nil
|
||||
usage.Rounds = rounds
|
||||
usage.ToolCount = len(usage.Tools)
|
||||
return text, usage, nil
|
||||
}
|
||||
if rounds >= maxToolRounds {
|
||||
return text, nil
|
||||
usage.Rounds = rounds
|
||||
usage.ToolCount = len(usage.Tools)
|
||||
return text, usage, nil
|
||||
}
|
||||
current = append(current, buildMcpAssistantMessage(text, pending))
|
||||
toolMsgs := executePendingTools(claims, pending, refMap, nil, nil)
|
||||
@@ -923,6 +971,7 @@ func callAITools(provider models.BackendAiProvider, model string, systemPrompt s
|
||||
return "", nil, aiUsage{}, fmt.Errorf("API返回错误状态 %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
var result struct {
|
||||
Model string `json:"model"`
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
@@ -955,6 +1004,10 @@ func callAITools(provider models.BackendAiProvider, model string, systemPrompt s
|
||||
TotalTokens: result.Usage.TotalTokens,
|
||||
}
|
||||
}
|
||||
// 接口实际返回的使用模型(网关可能将 auto 解析为具体模型)
|
||||
if result.Model != "" {
|
||||
usage.Model = result.Model
|
||||
}
|
||||
content := result.Choices[0].Message.Content
|
||||
var pending []pendingToolCall
|
||||
for _, tc := range result.Choices[0].Message.ToolCalls {
|
||||
@@ -1029,6 +1082,8 @@ func callAITools(provider models.BackendAiProvider, model string, systemPrompt s
|
||||
TotalTokens: result.Usage.InputTokens + result.Usage.OutputTokens,
|
||||
}
|
||||
}
|
||||
// Anthropic 响应不回传模型名,记录请求使用的模型
|
||||
usage.Model = model
|
||||
var text strings.Builder
|
||||
var pending []pendingToolCall
|
||||
for _, block := range result.Content {
|
||||
@@ -1129,6 +1184,7 @@ func callOpenAIStreamTools(provider models.BackendAiProvider, model string, syst
|
||||
break
|
||||
}
|
||||
var chunk struct {
|
||||
Model string `json:"model"`
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
@@ -1152,11 +1208,12 @@ func callOpenAIStreamTools(provider models.BackendAiProvider, model string, syst
|
||||
return "", nil, aiUsage{}, fmt.Errorf(chunk.Error.Message)
|
||||
}
|
||||
if chunk.Usage != nil {
|
||||
usage = aiUsage{
|
||||
PromptTokens: chunk.Usage.PromptTokens,
|
||||
CompletionTokens: chunk.Usage.CompletionTokens,
|
||||
TotalTokens: chunk.Usage.TotalTokens,
|
||||
}
|
||||
usage.PromptTokens = chunk.Usage.PromptTokens
|
||||
usage.CompletionTokens = chunk.Usage.CompletionTokens
|
||||
usage.TotalTokens = chunk.Usage.TotalTokens
|
||||
}
|
||||
if chunk.Model != "" {
|
||||
usage.Model = chunk.Model
|
||||
}
|
||||
if len(chunk.Choices) == 0 {
|
||||
continue
|
||||
|
||||
@@ -240,7 +240,7 @@ func (c *BackendAiSmartGenerateController) GenerateCompany() {
|
||||
}
|
||||
|
||||
// 调用AI(非流式,含已启用 MCP 工具:AI 会先调用天眼查等工具获取真实数据再生成)
|
||||
reply, err := runToolLoop(claims, provider, useModel, "", messages)
|
||||
reply, usage, err := runToolLoop(claims, provider, useModel, "", messages)
|
||||
if err != nil {
|
||||
c.sgJsonErr(500, 500, "AI生成失败: "+err.Error())
|
||||
return
|
||||
@@ -269,12 +269,16 @@ func (c *BackendAiSmartGenerateController) GenerateCompany() {
|
||||
"error": "AI返回格式异常,无法解析JSON",
|
||||
"name": p.CompanyName,
|
||||
"type": p.Type,
|
||||
"model": usage.Model,
|
||||
"tools": usage.Tools,
|
||||
"tool_count": usage.ToolCount,
|
||||
"rounds": usage.Rounds,
|
||||
"generate_time": time.Now().Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
return
|
||||
}
|
||||
// KV兜底成功,继续走后面的结果组装
|
||||
c.sgOk(smartGenResult(reply, result, p.CompanyName, p.Type, nameField))
|
||||
c.sgOk(smartGenResult(reply, result, p.CompanyName, p.Type, nameField, usage))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -285,17 +289,31 @@ func (c *BackendAiSmartGenerateController) GenerateCompany() {
|
||||
"error": fmt.Sprintf("解析JSON失败: %v", err),
|
||||
"name": p.CompanyName,
|
||||
"type": p.Type,
|
||||
"model": usage.Model,
|
||||
"tools": usage.Tools,
|
||||
"tool_count": usage.ToolCount,
|
||||
"rounds": usage.Rounds,
|
||||
"generate_time": time.Now().Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.sgOk(smartGenResult(reply, result, p.CompanyName, p.Type, nameField))
|
||||
c.sgOk(smartGenResult(reply, result, p.CompanyName, p.Type, nameField, usage))
|
||||
}
|
||||
|
||||
// aiGenNotFound 判断 AI 回复是否明确表示未查到企业信息(MCP 工具查询无结果)
|
||||
func aiGenNotFound(raw string) bool {
|
||||
for _, kw := range []string{"未找到", "未匹配", "查无", "未查询到", "未检索到", "no matching", "not found"} {
|
||||
if strings.Contains(raw, kw) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// smartGenResult 组装智能生成成功响应(含完整AI回复 raw),并对表单受限字段做规整
|
||||
func smartGenResult(reply string, result map[string]interface{}, companyName, genType, nameField string) map[string]interface{} {
|
||||
func smartGenResult(reply string, result map[string]interface{}, companyName, genType, nameField string, usage *aiToolUsage) map[string]interface{} {
|
||||
// 确保公司名称字段存在
|
||||
if _, ok := result[nameField]; !ok || result[nameField] == "" {
|
||||
result[nameField] = companyName
|
||||
@@ -317,10 +335,15 @@ func smartGenResult(reply string, result map[string]interface{}, companyName, ge
|
||||
crossFillPhone(result, "contact_phone", "registered_phone")
|
||||
return map[string]interface{}{
|
||||
"parsed": true,
|
||||
"found": !aiGenNotFound(reply), // MCP 是否查到企业信息
|
||||
"data": result,
|
||||
"raw": reply, // AI完整回复(含企业画像markdown,供前端"AI响应数据"展示)
|
||||
"name": companyName,
|
||||
"type": genType,
|
||||
"model": usage.Model, // 实际使用的模型
|
||||
"tools": usage.Tools, // 实际调用的 MCP 工具(server + tool)
|
||||
"tool_count": usage.ToolCount,
|
||||
"rounds": usage.Rounds,
|
||||
"generate_time": time.Now().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// 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})
|
||||
}
|
||||
|
||||
// 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("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"`
|
||||
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: buildMobiles(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"`
|
||||
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 = buildMobiles(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)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
)
|
||||
|
||||
// CRM 客户 / 供应商的删除权限规则:
|
||||
// 1. 平台管理员(UserType=platform)可删除;
|
||||
// 2. 租户管理员(绑定「租户管理员」角色,或未分配角色/角色未配置权限等既有"全权限"情形)可删除;
|
||||
// 3. 记录的创建人可删除自己创建的记录。
|
||||
// 其余用户一律禁止删除。删除本身为软删除(置 delete_time)。
|
||||
|
||||
// canDeleteCrmRecord 判断当前用户是否有权删除指定记录。
|
||||
// createUserID 为记录的创建人用户ID(历史数据可能为空)。
|
||||
func canDeleteCrmRecord(claims *jwtutil.Claims, createUserID string) bool {
|
||||
if claims == nil {
|
||||
return false
|
||||
}
|
||||
// 平台管理员
|
||||
if claims.UserType == "platform" {
|
||||
return true
|
||||
}
|
||||
// 创建人本人
|
||||
uid := fmt.Sprintf("%d", claims.UserID)
|
||||
if createUserID != "" && createUserID == uid {
|
||||
return true
|
||||
}
|
||||
// 租户管理员 / 具备全权限的租户用户
|
||||
return isTenantAdmin(claims.UserID, claims.TenantId)
|
||||
}
|
||||
|
||||
// isTenantAdmin 判断用户在指定租户内是否具备管理员(全权限)身份。
|
||||
// 判定与既有权限约定保持一致:
|
||||
// - 未分配角色(group_id=0)视为全权限;
|
||||
// - 角色名为「租户管理员」视为管理员;
|
||||
// - 角色未配置 rights(空/空数组/null)视为全权限。
|
||||
func isTenantAdmin(userID int, tenantID int) bool {
|
||||
if tenantID <= 0 || models.Orm == nil {
|
||||
return false
|
||||
}
|
||||
var tu models.SystemTenantUser
|
||||
if err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("uid", userID).
|
||||
Filter("tid", tenantID).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&tu); err != nil {
|
||||
return false
|
||||
}
|
||||
if tu.GroupID == 0 {
|
||||
return true
|
||||
}
|
||||
var role models.AdminRole
|
||||
if err := models.Orm.QueryTable(new(models.AdminRole)).
|
||||
Filter("id", tu.GroupID).
|
||||
Filter("cid", 2).
|
||||
Filter("tenant_id", tenantID).
|
||||
One(&role); err != nil {
|
||||
return false
|
||||
}
|
||||
if role.Name == "租户管理员" {
|
||||
return true
|
||||
}
|
||||
rights := ""
|
||||
if role.Rights != nil {
|
||||
rights = strings.TrimSpace(*role.Rights)
|
||||
}
|
||||
return rights == "" || rights == "[]" || rights == "null"
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// BackendCrmPoolController 客户公海控制器
|
||||
// 公海语义:in_pool=1 的客户处于公海中(租户共享,无个人负责人);
|
||||
// 移入公海会置 in_pool=1 并释放负责人,领取/分配/移出会置 in_pool=0 并绑定负责人。
|
||||
type BackendCrmPoolController struct {
|
||||
beego.Controller
|
||||
}
|
||||
|
||||
func (c *BackendCrmPoolController) poolClaims() (*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 *BackendCrmPoolController) poolJsonErr(httpStatus, bizCode int, msg string) {
|
||||
c.Ctx.Output.SetStatus(httpStatus)
|
||||
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
func (c *BackendCrmPoolController) poolOk(data interface{}) {
|
||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// List GET /backend/crm/pool/list 公海列表(无负责人、租户内共享)
|
||||
func (c *BackendCrmPoolController) List() {
|
||||
claims, err := c.poolClaims()
|
||||
if err != nil {
|
||||
c.poolJsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := c.GetInt("page", 1)
|
||||
pageSize, _ := c.GetInt("pageSize", 20)
|
||||
keyword := strings.TrimSpace(c.GetString("keyword"))
|
||||
customerType := strings.TrimSpace(c.GetString("customer_type"))
|
||||
customerLevel := strings.TrimSpace(c.GetString("customer_level"))
|
||||
startTime := strings.TrimSpace(c.GetString("start_time"))
|
||||
endTime := strings.TrimSpace(c.GetString("end_time"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
cond := orm.NewCondition().
|
||||
And("tenant_id", fmt.Sprintf("%d", claims.TenantId)).
|
||||
And("delete_time__isnull", true).
|
||||
And("in_pool", 1)
|
||||
if keyword != "" {
|
||||
kw := orm.NewCondition().
|
||||
Or("customer_name__contains", keyword).
|
||||
Or("contact_person__contains", keyword).
|
||||
Or("contact_phone__contains", keyword)
|
||||
cond = cond.AndCond(kw)
|
||||
}
|
||||
if customerType != "" {
|
||||
cond = cond.And("customer_type", customerType)
|
||||
}
|
||||
if customerLevel != "" {
|
||||
cond = cond.And("customer_level", customerLevel)
|
||||
}
|
||||
if startTime != "" {
|
||||
cond = cond.And("pool_in_time__gte", startTime)
|
||||
}
|
||||
if endTime != "" {
|
||||
cond = cond.And("pool_in_time__lte", endTime+" 23:59:59")
|
||||
}
|
||||
|
||||
qs := models.Orm.QueryTable(new(models.TenantCrmCustomer)).SetCond(cond)
|
||||
total, _ := qs.Count()
|
||||
var list []models.TenantCrmCustomer
|
||||
if total > 0 {
|
||||
_, _ = qs.OrderBy("-pool_in_time").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
|
||||
}
|
||||
|
||||
c.poolOk(map[string]interface{}{
|
||||
"list": list,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// Move POST /backend/crm/pool/move 移入公海:释放负责人,记录原负责人与原因
|
||||
func (c *BackendCrmPoolController) Move() {
|
||||
claims, err := c.poolClaims()
|
||||
if err != nil {
|
||||
c.poolJsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
var p struct {
|
||||
IDs []uint64 `json:"ids"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
c.poolJsonErr(400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
if len(p.IDs) == 0 {
|
||||
c.poolJsonErr(400, 400, "请选择要移入公海客户")
|
||||
return
|
||||
}
|
||||
reason := strings.TrimSpace(p.Reason)
|
||||
if reason == "" {
|
||||
c.poolJsonErr(400, 400, "请填写移入原因")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
updated := 0
|
||||
for _, id := range p.IDs {
|
||||
var cust models.TenantCrmCustomer
|
||||
err := models.Orm.QueryTable(new(models.TenantCrmCustomer)).
|
||||
Filter("id", id).
|
||||
Filter("tenant_id", fmt.Sprintf("%d", claims.TenantId)).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&cust)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// 记录原负责人(移入公海前的负责人),再清空负责人并置入公海
|
||||
cust.LastOwnerName = cust.OwnerUserName
|
||||
cust.OwnerUserID = ""
|
||||
cust.OwnerUserName = ""
|
||||
cust.InPool = 1
|
||||
cust.PoolReason = reason
|
||||
cust.PoolInTime = &now
|
||||
cust.UpdateTime = now
|
||||
if _, err := models.Orm.Update(&cust,
|
||||
"last_owner_name", "owner_user_id", "owner_user_name", "in_pool",
|
||||
"pool_reason", "pool_in_time", "update_time"); err == nil {
|
||||
updated++
|
||||
}
|
||||
}
|
||||
c.poolOk(map[string]interface{}{"updated": updated})
|
||||
}
|
||||
|
||||
// Claim POST /backend/crm/pool/claim 领取:将公海客户归属为当前登录用户
|
||||
func (c *BackendCrmPoolController) Claim() {
|
||||
claims, err := c.poolClaims()
|
||||
if err != nil {
|
||||
c.poolJsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
var p struct {
|
||||
IDs []uint64 `json:"ids"`
|
||||
}
|
||||
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
c.poolJsonErr(400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
if len(p.IDs) == 0 {
|
||||
c.poolJsonErr(400, 400, "请选择客户")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
uid := fmt.Sprintf("%d", claims.UserID)
|
||||
updated := 0
|
||||
for _, id := range p.IDs {
|
||||
var cust models.TenantCrmCustomer
|
||||
err := models.Orm.QueryTable(new(models.TenantCrmCustomer)).
|
||||
Filter("id", id).
|
||||
Filter("tenant_id", fmt.Sprintf("%d", claims.TenantId)).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&cust)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
cust.OwnerUserID = uid
|
||||
cust.OwnerUserName = claims.Username
|
||||
cust.InPool = 0
|
||||
cust.PoolInTime = nil
|
||||
cust.PoolReason = ""
|
||||
cust.UpdateTime = now
|
||||
if _, err := models.Orm.Update(&cust,
|
||||
"owner_user_id", "owner_user_name", "in_pool", "pool_in_time", "pool_reason", "update_time"); err == nil {
|
||||
updated++
|
||||
}
|
||||
}
|
||||
c.poolOk(map[string]interface{}{"updated": updated})
|
||||
}
|
||||
|
||||
// Assign POST /backend/crm/pool/assign 分配:将公海客户分配给指定负责人
|
||||
func (c *BackendCrmPoolController) Assign() {
|
||||
claims, err := c.poolClaims()
|
||||
if err != nil {
|
||||
c.poolJsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
var p struct {
|
||||
IDs []uint64 `json:"ids"`
|
||||
OwnerUserID interface{} `json:"owner_user_id"`
|
||||
OwnerUserName string `json:"owner_user_name"`
|
||||
}
|
||||
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
c.poolJsonErr(400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
if len(p.IDs) == 0 {
|
||||
c.poolJsonErr(400, 400, "请选择客户")
|
||||
return
|
||||
}
|
||||
ownerID := strings.TrimSpace(fmt.Sprintf("%v", p.OwnerUserID))
|
||||
if ownerID == "" {
|
||||
c.poolJsonErr(400, 400, "请指定负责人")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
updated := 0
|
||||
for _, id := range p.IDs {
|
||||
var cust models.TenantCrmCustomer
|
||||
err := models.Orm.QueryTable(new(models.TenantCrmCustomer)).
|
||||
Filter("id", id).
|
||||
Filter("tenant_id", fmt.Sprintf("%d", claims.TenantId)).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&cust)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
cust.OwnerUserID = ownerID
|
||||
cust.OwnerUserName = strings.TrimSpace(p.OwnerUserName)
|
||||
cust.InPool = 0
|
||||
cust.PoolInTime = nil
|
||||
cust.PoolReason = ""
|
||||
cust.UpdateTime = now
|
||||
if _, err := models.Orm.Update(&cust,
|
||||
"owner_user_id", "owner_user_name", "in_pool", "pool_in_time", "pool_reason", "update_time"); err == nil {
|
||||
updated++
|
||||
}
|
||||
}
|
||||
c.poolOk(map[string]interface{}{"updated": updated})
|
||||
}
|
||||
@@ -101,9 +101,11 @@ func (c *BackendErpCustomerController) List() {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
// 已移入公海(in_pool=1)的客户不在客户列表中展示,公海请到 /backend/crm/pool/list 查看
|
||||
qs := models.Orm.QueryTable(new(models.TenantCrmCustomer)).
|
||||
Filter("tenant_id", fmt.Sprintf("%d", claims.TenantId)).
|
||||
Filter("delete_time__isnull", true)
|
||||
Filter("delete_time__isnull", true).
|
||||
Filter("in_pool", 0)
|
||||
|
||||
if keyword != "" {
|
||||
qs = qs.Filter("customer_name__contains", keyword)
|
||||
@@ -224,6 +226,9 @@ func (c *BackendErpCustomerController) Create() {
|
||||
BankAccount: strings.TrimSpace(p.BankAccount),
|
||||
RegisteredAddress: strings.TrimSpace(p.RegisteredAddress),
|
||||
RegisteredPhone: strings.TrimSpace(p.RegisteredPhone),
|
||||
OwnerUserID: fmt.Sprintf("%d", claims.UserID),
|
||||
OwnerUserName: claims.Username,
|
||||
CreateUserID: fmt.Sprintf("%d", claims.UserID),
|
||||
CreateTime: time.Now(),
|
||||
UpdateTime: time.Now(),
|
||||
}
|
||||
@@ -366,6 +371,22 @@ func (c *BackendErpCustomerController) Delete() {
|
||||
return
|
||||
}
|
||||
|
||||
// 删除权限:仅平台管理员、租户管理员或创建人本人可删除
|
||||
var cust models.TenantCrmCustomer
|
||||
if err := models.Orm.QueryTable(new(models.TenantCrmCustomer)).
|
||||
Filter("id", id).
|
||||
Filter("tenant_id", fmt.Sprintf("%d", claims.TenantId)).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&cust); err != nil {
|
||||
c.customerJsonErr(404, 404, "客户未找到")
|
||||
return
|
||||
}
|
||||
if !canDeleteCrmRecord(claims, cust.CreateUserID) {
|
||||
c.customerJsonErr(403, 403, "只有创建人、租户管理员或平台管理员可以删除该客户")
|
||||
return
|
||||
}
|
||||
|
||||
// 软删除:仅置 delete_time,不物理删除
|
||||
now := time.Now()
|
||||
_, err = models.Orm.QueryTable(new(models.TenantCrmCustomer)).
|
||||
Filter("id", id).
|
||||
|
||||
@@ -224,6 +224,7 @@ func (c *BackendErpSupplierController) Create() {
|
||||
BankAccount: strings.TrimSpace(p.BankAccount),
|
||||
RegisteredAddress: strings.TrimSpace(p.RegisteredAddress),
|
||||
RegisteredPhone: strings.TrimSpace(p.RegisteredPhone),
|
||||
CreateUserID: fmt.Sprintf("%d", claims.UserID),
|
||||
CreateTime: time.Now(),
|
||||
UpdateTime: time.Now(),
|
||||
}
|
||||
@@ -366,6 +367,22 @@ func (c *BackendErpSupplierController) Delete() {
|
||||
return
|
||||
}
|
||||
|
||||
// 删除权限:仅平台管理员、租户管理员或创建人本人可删除
|
||||
var supplier models.TenantCrmSupplier
|
||||
if err := models.Orm.QueryTable(new(models.TenantCrmSupplier)).
|
||||
Filter("id", id).
|
||||
Filter("tenant_id", fmt.Sprintf("%d", claims.TenantId)).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&supplier); err != nil {
|
||||
c.supplierJsonErr(404, 404, "供应商未找到")
|
||||
return
|
||||
}
|
||||
if !canDeleteCrmRecord(claims, supplier.CreateUserID) {
|
||||
c.supplierJsonErr(403, 403, "只有创建人、租户管理员或平台管理员可以删除该供应商")
|
||||
return
|
||||
}
|
||||
|
||||
// 软删除:仅置 delete_time,不物理删除
|
||||
now := time.Now()
|
||||
_, err = models.Orm.QueryTable(new(models.TenantCrmSupplier)).
|
||||
Filter("id", id).
|
||||
|
||||
@@ -599,6 +599,13 @@ CREATE TABLE `yz_tenant_crm_customer` (
|
||||
`bank_account` varchar(50) DEFAULT '' COMMENT '开户行账号',
|
||||
`registered_address` varchar(255) DEFAULT '' COMMENT '注册地址',
|
||||
`registered_phone` varchar(50) DEFAULT '' COMMENT '注册电话',
|
||||
`owner_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '负责人ID,空表示在公海',
|
||||
`owner_user_name` varchar(128) NOT NULL DEFAULT '' COMMENT '负责人姓名',
|
||||
`last_owner_name` varchar(128) NOT NULL DEFAULT '' COMMENT '移入公海前的原负责人',
|
||||
`pool_in_time` datetime DEFAULT NULL COMMENT '进入公海时间',
|
||||
`pool_reason` varchar(200) NOT NULL DEFAULT '' COMMENT '移入公海原因',
|
||||
`in_pool` tinyint NOT NULL DEFAULT 0 COMMENT '是否在公海:0-否 1-是',
|
||||
`create_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人用户ID,用于删除权限校验',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`delete_time` datetime DEFAULT NULL COMMENT '删除时间',
|
||||
@@ -639,6 +646,7 @@ CREATE TABLE `yz_tenant_crm_supplier` (
|
||||
`bank_account` varchar(50) DEFAULT '' COMMENT '开户行账号',
|
||||
`registered_address` varchar(255) DEFAULT '' COMMENT '注册地址',
|
||||
`registered_phone` varchar(50) DEFAULT '' COMMENT '注册电话',
|
||||
`create_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人用户ID,用于删除权限校验',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`delete_time` datetime DEFAULT NULL COMMENT '删除时间',
|
||||
|
||||
@@ -10,8 +10,8 @@ type ErpCompanyContact struct {
|
||||
CompanyID uint64 `orm:"column(company_id)" json:"company_id"`
|
||||
Name string `orm:"column(name);size(50)" json:"name"`
|
||||
Gender int8 `orm:"column(gender);default(0)" json:"gender"` // 0-未知 1-男 2-女
|
||||
Phone string `orm:"column(phone);size(20)" json:"phone"`
|
||||
Mobiles string `orm:"column(mobiles);type(text);null" json:"mobiles"` // JSON数组,最多5个
|
||||
Phone string `orm:"column(phone);size(20)" json:"phone"` // 座机(与 ERP 联系人语义一致)
|
||||
Mobiles string `orm:"column(mobiles);type(text);null" json:"mobiles"` // 手机号,JSON数组,最多5个
|
||||
Wechat string `orm:"column(wechat);size(50)" json:"wechat"`
|
||||
QQ string `orm:"column(qq);size(50)" json:"qq"`
|
||||
Dingtalk string `orm:"column(dingtalk);size(50)" json:"dingtalk"`
|
||||
|
||||
@@ -132,4 +132,50 @@ func Init(_ string) {
|
||||
EnsureAdminRoleTenantColumn()
|
||||
EnsureAdminRoleCustomColumn()
|
||||
EnsureTenantUserOrgColumn()
|
||||
EnsureCrmCustomerPoolColumns()
|
||||
EnsureCrmCreateUserColumn()
|
||||
}
|
||||
|
||||
|
||||
// EnsureCrmCreateUserColumn 补齐客户/供应商的创建人字段(用于删除权限校验)。
|
||||
// 同样兼容历史表名差异,表不存在或列已存在时忽略错误。
|
||||
func EnsureCrmCreateUserColumn() {
|
||||
tables := []string{
|
||||
new(TenantCrmCustomer).TableName(), "yz_tenant_crm_customer",
|
||||
new(TenantCrmSupplier).TableName(), "yz_tenant_crm_supplier",
|
||||
}
|
||||
for _, table := range tables {
|
||||
sql := "ALTER TABLE " + table +
|
||||
" ADD COLUMN create_user_id varchar(64) NOT NULL DEFAULT '' COMMENT '创建人用户ID,用于删除权限校验'"
|
||||
if _, err := Orm.Raw(sql).Exec(); err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureCrmCustomerPoolColumns 补齐客户公海相关字段。
|
||||
// 兼容历史表名差异:模型 TableName 为 yz_backend_erp_customer,初始化 SQL 中为 yz_tenant_crm_customer,
|
||||
// 对两个候选表名都执行 ALTER(表不存在或列已存在时忽略错误)。
|
||||
func EnsureCrmCustomerPoolColumns() {
|
||||
candidates := []string{
|
||||
new(TenantCrmCustomer).TableName(),
|
||||
"yz_tenant_crm_customer",
|
||||
}
|
||||
cols := []string{
|
||||
"in_pool tinyint NOT NULL DEFAULT 0 COMMENT '是否在公海:0-否 1-是'",
|
||||
"owner_user_id varchar(64) NOT NULL DEFAULT '' COMMENT '负责人ID,公海中为空'",
|
||||
"owner_user_name varchar(128) NOT NULL DEFAULT '' COMMENT '负责人姓名'",
|
||||
"last_owner_name varchar(128) NOT NULL DEFAULT '' COMMENT '移入公海前的原负责人'",
|
||||
"pool_in_time datetime DEFAULT NULL COMMENT '进入公海时间'",
|
||||
"pool_reason varchar(200) NOT NULL DEFAULT '' COMMENT '移入公海原因'",
|
||||
}
|
||||
for _, table := range candidates {
|
||||
for _, col := range cols {
|
||||
sql := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s", table, col)
|
||||
if _, err := Orm.Raw(sql).Exec(); err != nil {
|
||||
// 列已存在或表不存在时忽略
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,14 @@ type TenantCrmCustomer struct {
|
||||
BankAccount string `orm:"column(bank_account);size(50)" json:"bank_account"`
|
||||
RegisteredAddress string `orm:"column(registered_address);size(255)" json:"registered_address"`
|
||||
RegisteredPhone string `orm:"column(registered_phone);size(50)" json:"registered_phone"`
|
||||
// 公海相关:in_pool=1 表示客户在公海中(租户共享,无个人负责人)
|
||||
InPool int8 `orm:"column(in_pool);default(0)" json:"in_pool"` // 是否在公海:0-否 1-是
|
||||
OwnerUserID string `orm:"column(owner_user_id);size(64);null" json:"owner_user_id"` // 当前负责人ID,公海中为空
|
||||
OwnerUserName string `orm:"column(owner_user_name);size(128);null" json:"owner_user_name"` // 当前负责人姓名
|
||||
LastOwnerName string `orm:"column(last_owner_name);size(128);null" json:"last_owner_name"` // 移入公海前的原负责人
|
||||
PoolInTime *time.Time `orm:"column(pool_in_time);type(datetime);null" json:"pool_in_time"` // 进入公海时间
|
||||
PoolReason string `orm:"column(pool_reason);size(200);null" json:"pool_reason"` // 移入公海原因
|
||||
CreateUserID string `orm:"column(create_user_id);size(64);null" json:"create_user_id"` // 创建人用户ID,用于删除权限校验
|
||||
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
|
||||
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
|
||||
|
||||
@@ -32,6 +32,7 @@ type TenantCrmSupplier struct {
|
||||
BankAccount string `orm:"column(bank_account);size(50)" json:"bank_account"`
|
||||
RegisteredAddress string `orm:"column(registered_address);size(255)" json:"registered_address"`
|
||||
RegisteredPhone string `orm:"column(registered_phone);size(50)" json:"registered_phone"`
|
||||
CreateUserID string `orm:"column(create_user_id);size(64);null" json:"create_user_id"` // 创建人用户ID,用于删除权限校验
|
||||
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
|
||||
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
|
||||
|
||||
@@ -372,6 +372,18 @@ func registerOrganizationRoutes(module string) {
|
||||
beego.Router("/backend/erp/customer/:id", &controllers.BackendErpCustomerController{}, "get:Detail;put:Update;delete:Delete")
|
||||
beego.Router("/backend/erp/customer/:id/status", &controllers.BackendErpCustomerController{}, "post:ToggleStatus")
|
||||
|
||||
// CRM联系人(客户/供应商对接人)
|
||||
beego.Router("/backend/crm/contact/list", &controllers.BackendCrmContactController{}, "get:List")
|
||||
beego.Router("/backend/crm/contact/add", &controllers.BackendCrmContactController{}, "post:Add")
|
||||
beego.Router("/backend/crm/contact/edit", &controllers.BackendCrmContactController{}, "post:Edit")
|
||||
beego.Router("/backend/crm/contact/delete", &controllers.BackendCrmContactController{}, "post:Delete")
|
||||
|
||||
// CRM客户公海
|
||||
beego.Router("/backend/crm/pool/list", &controllers.BackendCrmPoolController{}, "get:List")
|
||||
beego.Router("/backend/crm/pool/move", &controllers.BackendCrmPoolController{}, "post:Move")
|
||||
beego.Router("/backend/crm/pool/claim", &controllers.BackendCrmPoolController{}, "post:Claim")
|
||||
beego.Router("/backend/crm/pool/assign", &controllers.BackendCrmPoolController{}, "post:Assign")
|
||||
|
||||
// ERP供应商管理
|
||||
beego.Router("/backend/erp/supplier/list", &controllers.BackendErpSupplierController{}, "get:List")
|
||||
beego.Router("/backend/erp/supplier", &controllers.BackendErpSupplierController{}, "post:Create")
|
||||
|
||||
Reference in New Issue
Block a user