优化线索、商机、项目相关开发
This commit is contained in:
@@ -88,8 +88,10 @@ func (c *BackendCrmBusinessController) List() {
|
||||
total, _ := qs.Count()
|
||||
var list []models.TenantCrmBusiness
|
||||
if total > 0 {
|
||||
_, _ = qs.OrderBy("-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
|
||||
// 未转化的排前面,已转化的(locked=1)沉到列表下方
|
||||
_, _ = qs.OrderBy("locked", "-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
|
||||
}
|
||||
attachClueName(tenantID, list)
|
||||
pipelineOk(&c.Controller, map[string]interface{}{
|
||||
"list": list, "total": total, "page": page, "pageSize": pageSize,
|
||||
})
|
||||
@@ -115,6 +117,9 @@ func (c *BackendCrmBusinessController) Detail() {
|
||||
pipelineErr(&c.Controller, 404, 404, "商机未找到")
|
||||
return
|
||||
}
|
||||
tmp := []models.TenantCrmBusiness{biz}
|
||||
attachClueName(pipelineTenantID(claims), tmp)
|
||||
biz.ClueName = tmp[0].ClueName
|
||||
pipelineOk(&c.Controller, biz)
|
||||
}
|
||||
|
||||
@@ -159,7 +164,7 @@ func (c *BackendCrmBusinessController) Create() {
|
||||
}
|
||||
|
||||
ownerID := firstNonEmpty(p.OwnerUserID, pipelineUID(claims))
|
||||
ownerName := firstNonEmpty(p.OwnerUserName, claims.Username)
|
||||
ownerName := firstNonEmpty(p.OwnerUserName, resolveUserName(claims))
|
||||
stage := firstNonEmpty(p.Stage, "1")
|
||||
level := firstNonEmpty(p.Level, "2")
|
||||
now := time.Now()
|
||||
@@ -301,6 +306,10 @@ func (c *BackendCrmBusinessController) Delete() {
|
||||
pipelineErr(&c.Controller, 404, 404, "商机未找到")
|
||||
return
|
||||
}
|
||||
if biz.Locked == 1 || biz.Status == 2 {
|
||||
pipelineErr(&c.Controller, 400, 400, "商机已转化并锁定,不能删除")
|
||||
return
|
||||
}
|
||||
if !canDeleteCrmRecord(claims, biz.CreateUserID) {
|
||||
pipelineErr(&c.Controller, 403, 403, "只有创建人、租户管理员或平台管理员可以删除该商机")
|
||||
return
|
||||
|
||||
@@ -86,7 +86,8 @@ func (c *BackendCrmClueController) List() {
|
||||
total, _ := qs.Count()
|
||||
var list []models.TenantCrmClue
|
||||
if total > 0 {
|
||||
_, _ = qs.OrderBy("-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
|
||||
// 未转化的排前面,已转化的(locked=1)沉到列表下方
|
||||
_, _ = qs.OrderBy("locked", "-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
|
||||
}
|
||||
pipelineOk(&c.Controller, map[string]interface{}{
|
||||
"list": list, "total": total, "page": page, "pageSize": pageSize,
|
||||
@@ -145,7 +146,7 @@ func (c *BackendCrmClueController) Create() {
|
||||
ownerID = pipelineUID(claims)
|
||||
}
|
||||
if ownerName == "" {
|
||||
ownerName = claims.Username
|
||||
ownerName = resolveUserName(claims)
|
||||
}
|
||||
level := strings.TrimSpace(p.ClueLevel)
|
||||
if level == "" {
|
||||
@@ -271,6 +272,10 @@ func (c *BackendCrmClueController) Delete() {
|
||||
pipelineErr(&c.Controller, 404, 404, "线索未找到")
|
||||
return
|
||||
}
|
||||
if clue.Locked == 1 || clue.Status == 2 {
|
||||
pipelineErr(&c.Controller, 400, 400, "线索已转化并锁定,不能删除")
|
||||
return
|
||||
}
|
||||
if !canDeleteCrmRecord(claims, clue.CreateUserID) {
|
||||
pipelineErr(&c.Controller, 403, 403, "只有创建人、租户管理员或平台管理员可以删除该线索")
|
||||
return
|
||||
|
||||
@@ -7,10 +7,28 @@ import (
|
||||
|
||||
"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=项目
|
||||
|
||||
@@ -98,7 +116,7 @@ func crmWriteLog(tenantID string, relatedType int8, relatedID uint64, action, co
|
||||
operatorID, operatorName := "", ""
|
||||
if claims != nil {
|
||||
operatorID = pipelineUID(claims)
|
||||
operatorName = claims.Username
|
||||
operatorName = resolveUserName(claims)
|
||||
}
|
||||
log := models.TenantCrmOperateLog{
|
||||
TenantID: tenantID,
|
||||
@@ -245,3 +263,117 @@ func syncContactsToCompany(tenantID string, customerID uint64, contacts []models
|
||||
}
|
||||
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]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -201,7 +201,7 @@ func (c *BackendCrmPoolController) Claim() {
|
||||
continue
|
||||
}
|
||||
cust.OwnerUserID = uid
|
||||
cust.OwnerUserName = claims.Username
|
||||
cust.OwnerUserName = resolveUserName(claims)
|
||||
cust.InPool = 0
|
||||
cust.PoolInTime = nil
|
||||
cust.PoolReason = ""
|
||||
|
||||
@@ -3,11 +3,14 @@ package controllers
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
"server/services"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
@@ -78,6 +81,7 @@ func (c *BackendCrmProjectController) List() {
|
||||
if total > 0 {
|
||||
_, _ = qs.OrderBy("-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
|
||||
}
|
||||
attachBusinessName(tenantID, list)
|
||||
pipelineOk(&c.Controller, map[string]interface{}{
|
||||
"list": list, "total": total, "page": page, "pageSize": pageSize,
|
||||
})
|
||||
@@ -103,6 +107,15 @@ func (c *BackendCrmProjectController) Detail() {
|
||||
pipelineErr(&c.Controller, 404, 404, "项目未找到")
|
||||
return
|
||||
}
|
||||
tmp := []models.TenantCrmProject{proj}
|
||||
attachBusinessName(pipelineTenantID(claims), tmp)
|
||||
proj.BusinessName = tmp[0].BusinessName
|
||||
// 存量项目兜底:确保文档库「共享文档 / 项目文档」下已建立同名文件夹(失败静默,不影响详情)
|
||||
if proj.DocCategoryID == 0 {
|
||||
if cid, err := services.EnsureCrmProjectDocCategory(claims.TenantId, proj.ID, proj.ProjectName, 0); err == nil && cid > 0 {
|
||||
proj.DocCategoryID = cid
|
||||
}
|
||||
}
|
||||
pipelineOk(&c.Controller, proj)
|
||||
}
|
||||
|
||||
@@ -149,7 +162,7 @@ func (c *BackendCrmProjectController) Create() {
|
||||
CustomerID: custIDVal,
|
||||
CustomerName: customerName,
|
||||
OwnerUserID: firstNonEmpty(p.OwnerUserID, pipelineUID(claims)),
|
||||
OwnerUserName: firstNonEmpty(p.OwnerUserName, claims.Username),
|
||||
OwnerUserName: firstNonEmpty(p.OwnerUserName, resolveUserName(claims)),
|
||||
Industry: strings.TrimSpace(p.Industry),
|
||||
Amount: p.Amount,
|
||||
Status: status,
|
||||
@@ -169,8 +182,12 @@ func (c *BackendCrmProjectController) Create() {
|
||||
pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
// 项目建立后,同步在文档库「共享文档 / 项目文档」下建立同名文件夹(失败不影响项目创建)
|
||||
if cid, err := services.EnsureCrmProjectDocCategory(claims.TenantId, uint64(id), proj.ProjectName, 0); err == nil {
|
||||
proj.DocCategoryID = cid
|
||||
}
|
||||
crmWriteLog(tenantID, 3, uint64(id), "create", "创建项目:"+proj.ProjectName, claims)
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": id})
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": id, "doc_category_id": proj.DocCategoryID})
|
||||
}
|
||||
|
||||
// Update PUT /backend/crm/project/:id
|
||||
@@ -242,6 +259,10 @@ func (c *BackendCrmProjectController) Update() {
|
||||
pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
// 项目改名时同步重命名文档库中的项目文件夹(失败静默,不影响项目更新)
|
||||
if proj.DocCategoryID > 0 {
|
||||
_ = services.RenameCrmProjectDocCategory(claims.TenantId, proj.DocCategoryID, proj.ProjectName)
|
||||
}
|
||||
crmWriteLog(tenantID, 3, proj.ID, "update", "更新项目:"+proj.ProjectName, claims)
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": proj.ID})
|
||||
}
|
||||
@@ -280,3 +301,341 @@ func (c *BackendCrmProjectController) Delete() {
|
||||
}
|
||||
pipelineOk(&c.Controller, nil)
|
||||
}
|
||||
|
||||
// ========================== 项目文档(OA 文档库绑定) ==========================
|
||||
|
||||
// projectDocItem 项目文档条目:文档库文档 + 所在文件夹名称。
|
||||
type projectDocItem struct {
|
||||
models.OaDoc
|
||||
FolderName string `json:"folder_name"`
|
||||
}
|
||||
|
||||
// projectDocContext 解析项目并确保其在文档库「共享文档 / 项目文档」下的同名文件夹存在。
|
||||
// 存量项目(doc_category_id=0)在首次访问项目文档时自动补建,保证历史项目也能在文档库中看到。
|
||||
// 失败时已写出错误响应,返回 ok=false。
|
||||
func (c *BackendCrmProjectController) projectDocContext(claims *jwtutil.Claims) (*models.TenantCrmProject, uint64, bool) {
|
||||
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||
if id == 0 {
|
||||
pipelineErr(&c.Controller, 400, 400, "无效的ID")
|
||||
return nil, 0, false
|
||||
}
|
||||
var proj models.TenantCrmProject
|
||||
if err := models.Orm.QueryTable(new(models.TenantCrmProject)).
|
||||
Filter("id", id).Filter("tenant_id", pipelineTenantID(claims)).
|
||||
Filter("delete_time__isnull", true).One(&proj); err != nil {
|
||||
pipelineErr(&c.Controller, 404, 404, "项目未找到")
|
||||
return nil, 0, false
|
||||
}
|
||||
cid, err := services.EnsureCrmProjectDocCategory(claims.TenantId, proj.ID, proj.ProjectName, proj.DocCategoryID)
|
||||
if err != nil || cid == 0 {
|
||||
pipelineErr(&c.Controller, 500, 500, "项目文档文件夹准备失败")
|
||||
return nil, 0, false
|
||||
}
|
||||
proj.DocCategoryID = cid
|
||||
return &proj, cid, true
|
||||
}
|
||||
|
||||
// DocList GET /backend/crm/project/:id/docs
|
||||
// 项目文档列表:读取文档库中该项目文件夹(含子文件夹)下的全部文档,与文档库内容实时一致。
|
||||
func (c *BackendCrmProjectController) DocList() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
proj, cid, ok := c.projectDocContext(claims)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, _ := c.GetInt("page", 1)
|
||||
pageSize, _ := c.GetInt("pageSize", 20)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 200 {
|
||||
pageSize = 20
|
||||
}
|
||||
actor := services.NewOaDocActor(claims.TenantId, uint64(claims.UserID))
|
||||
// 可选按文件夹筛选(含其子文件夹);不属于项目文件夹的参数回落到项目根,避免越权查询
|
||||
filterCategory := cid
|
||||
if v, _ := c.GetUint64("category_id"); v > 0 && services.CrmProjectDocCategoryInScope(actor, cid, v) {
|
||||
filterCategory = v
|
||||
}
|
||||
list, total, err := services.OaDocList(actor, services.OaDocListParams{
|
||||
Actor: actor,
|
||||
Scope: models.DocScopeShared,
|
||||
Keyword: strings.TrimSpace(c.GetString("keyword")),
|
||||
CategoryID: filterCategory,
|
||||
Status: -1,
|
||||
DocType: -1,
|
||||
Star: -1,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "查询失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
// 附带所在文件夹名称(项目文件夹本身或其中子分类)
|
||||
nameMap := services.CrmProjectDocCategoryNameMap(claims.TenantId)
|
||||
items := make([]projectDocItem, 0, len(list))
|
||||
for _, d := range list {
|
||||
folder := nameMap[d.CategoryID]
|
||||
if folder == "" {
|
||||
folder = proj.ProjectName
|
||||
}
|
||||
items = append(items, projectDocItem{OaDoc: d, FolderName: folder})
|
||||
}
|
||||
// 项目文件夹树(根为项目文件夹),供前端选择/新建子文件夹
|
||||
folders, err := services.CrmProjectDocFolderTree(actor, cid)
|
||||
if err != nil {
|
||||
folders = []*services.ProjectDocFolder{}
|
||||
}
|
||||
pipelineOk(&c.Controller, map[string]interface{}{
|
||||
"category_id": cid,
|
||||
"category_name": proj.ProjectName,
|
||||
"folders": folders,
|
||||
"list": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// DocUpload POST /backend/crm/project/:id/doc-upload
|
||||
// 上传项目文档:文件先经 POST /backend/uploadfile 上传,再调用本接口把元数据写入文档库项目文件夹,
|
||||
// 同时同步登记一条 CRM 附件,保证项目「附件」链路同样可见。
|
||||
func (c *BackendCrmProjectController) DocUpload() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
proj, cid, ok := c.projectDocContext(claims)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var p struct {
|
||||
FileID uint64 `json:"file_id"`
|
||||
FileName string `json:"file_name"`
|
||||
FileURL string `json:"file_url"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
Ext string `json:"ext"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
CategoryID uint64 `json:"category_id"` // 目标文件夹,0=项目根文件夹
|
||||
}
|
||||
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
pipelineErr(&c.Controller, 400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
fileName := strings.TrimSpace(p.FileName)
|
||||
fileURL := strings.TrimSpace(p.FileURL)
|
||||
if fileName == "" || fileURL == "" {
|
||||
pipelineErr(&c.Controller, 400, 400, "文件信息不完整")
|
||||
return
|
||||
}
|
||||
ext := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(p.Ext), "."))
|
||||
if ext == "" {
|
||||
ext = strings.ToLower(strings.TrimPrefix(filepath.Ext(fileName), "."))
|
||||
}
|
||||
var size uint64
|
||||
if p.FileSize > 0 {
|
||||
size = uint64(p.FileSize)
|
||||
}
|
||||
operatorName := resolveUserName(claims)
|
||||
actor := services.NewOaDocActor(claims.TenantId, uint64(claims.UserID))
|
||||
// 目标文件夹:默认项目根文件夹;指定时必须在项目文件夹内
|
||||
targetCategory := cid
|
||||
if p.CategoryID > 0 {
|
||||
if !services.CrmProjectDocCategoryInScope(actor, cid, p.CategoryID) {
|
||||
pipelineErr(&c.Controller, 403, 403, "只能上传到项目自己的文件夹内")
|
||||
return
|
||||
}
|
||||
targetCategory = p.CategoryID
|
||||
}
|
||||
doc, err := services.OaDocCreate(actor, operatorName, services.OaDocSaveParams{
|
||||
SetCategory: true,
|
||||
CategoryID: targetCategory,
|
||||
SetVisibility: true,
|
||||
Visibility: models.DocVisibilityPublic,
|
||||
Title: strings.TrimSpace(p.Title),
|
||||
FileID: p.FileID,
|
||||
FileURL: fileURL,
|
||||
FileName: fileName,
|
||||
Ext: ext,
|
||||
Size: size,
|
||||
Summary: strings.TrimSpace(p.Summary),
|
||||
Status: models.DocStatusPublish,
|
||||
OwnerID: uint64(claims.UserID),
|
||||
OwnerName: operatorName,
|
||||
})
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 400, 400, "同步文档库失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
// 同步登记 CRM 附件(失败不影响文档库结果)
|
||||
now := time.Now()
|
||||
var fileIDPtr *uint64
|
||||
if doc.FileID > 0 {
|
||||
fid := doc.FileID
|
||||
fileIDPtr = &fid
|
||||
}
|
||||
_, _ = models.Orm.Insert(&models.TenantCrmAttach{
|
||||
TenantID: pipelineTenantID(claims),
|
||||
RelatedType: 3,
|
||||
RelatedID: proj.ID,
|
||||
FileID: fileIDPtr,
|
||||
FileName: doc.FileName,
|
||||
FileURL: doc.FileURL,
|
||||
FileSize: int64(doc.Size),
|
||||
UploaderID: pipelineUID(claims),
|
||||
UploaderName: operatorName,
|
||||
CreateTime: now,
|
||||
UpdateTime: now,
|
||||
})
|
||||
crmWriteLog(pipelineTenantID(claims), 3, proj.ID, "doc", "上传项目文档:"+doc.Title, claims)
|
||||
pipelineOk(&c.Controller, doc)
|
||||
}
|
||||
|
||||
// DocDelete POST /backend/crm/project/:id/doc-delete
|
||||
// 删除项目文档:同步软删文档库文档与对应的 CRM 附件记录。
|
||||
func (c *BackendCrmProjectController) DocDelete() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
proj, cid, ok := c.projectDocContext(claims)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var p struct {
|
||||
DocID uint64 `json:"doc_id"`
|
||||
}
|
||||
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||
if err := json.Unmarshal(raw, &p); err != nil || p.DocID == 0 {
|
||||
pipelineErr(&c.Controller, 400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
actor := services.NewOaDocActor(claims.TenantId, uint64(claims.UserID))
|
||||
doc, err := services.OaDocGet(actor, p.DocID)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 404, 404, "文档不存在")
|
||||
return
|
||||
}
|
||||
// 仅允许删除本项目文件夹(含子文件夹)内的文档
|
||||
if !services.CrmProjectDocCategoryInScope(actor, cid, doc.CategoryID) {
|
||||
pipelineErr(&c.Controller, 403, 403, "该文档不属于当前项目")
|
||||
return
|
||||
}
|
||||
if _, err := services.OaDocDelete(actor, []uint64{doc.ID}); err != nil {
|
||||
pipelineErr(&c.Controller, 400, 400, err.Error())
|
||||
return
|
||||
}
|
||||
// 同步软删 CRM 附件记录(按文件ID匹配,缺失时按文件名+地址匹配)
|
||||
now := time.Now()
|
||||
q := models.Orm.QueryTable(new(models.TenantCrmAttach)).
|
||||
Filter("tenant_id", pipelineTenantID(claims)).
|
||||
Filter("related_type", 3).
|
||||
Filter("related_id", proj.ID).
|
||||
Filter("delete_time__isnull", true)
|
||||
if doc.FileID > 0 {
|
||||
q = q.Filter("file_id", doc.FileID)
|
||||
} else {
|
||||
q = q.Filter("file_name", doc.FileName).Filter("file_url", doc.FileURL)
|
||||
}
|
||||
_, _ = q.Update(map[string]interface{}{"delete_time": now, "update_time": now})
|
||||
crmWriteLog(pipelineTenantID(claims), 3, proj.ID, "doc", "删除项目文档:"+doc.Title, claims)
|
||||
pipelineOk(&c.Controller, nil)
|
||||
}
|
||||
|
||||
// DocFolderCreate POST /backend/crm/project/:id/doc-folder
|
||||
// 在项目文件夹下新建子文件夹(对应文档库中的文档分类),用于分类存放项目文档。
|
||||
// parent_id 缺省时建在项目根文件夹下;只允许在项目自己的文件夹树内创建。
|
||||
func (c *BackendCrmProjectController) DocFolderCreate() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
proj, cid, ok := c.projectDocContext(claims)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
ParentID uint64 `json:"parent_id"`
|
||||
}
|
||||
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
pipelineErr(&c.Controller, 400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(p.Name)
|
||||
if name == "" {
|
||||
pipelineErr(&c.Controller, 400, 400, "请输入文件夹名称")
|
||||
return
|
||||
}
|
||||
parentID := p.ParentID
|
||||
if parentID == 0 {
|
||||
parentID = cid
|
||||
}
|
||||
actor := services.NewOaDocActor(claims.TenantId, uint64(claims.UserID))
|
||||
if !services.CrmProjectDocCategoryInScope(actor, cid, parentID) {
|
||||
pipelineErr(&c.Controller, 403, 403, "只能在项目文件夹内创建子文件夹")
|
||||
return
|
||||
}
|
||||
item, err := services.OaDocCategoryCreate(actor, models.DocScopeShared, parentID, name, "项目文档:"+proj.ProjectName, 0)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 400, 400, err.Error())
|
||||
return
|
||||
}
|
||||
crmWriteLog(pipelineTenantID(claims), 3, proj.ID, "doc-folder", "新建项目文档文件夹:"+item.Name, claims)
|
||||
pipelineOk(&c.Controller, item)
|
||||
}
|
||||
|
||||
// DocFolderDelete POST /backend/crm/project/:id/doc-folder-delete
|
||||
// 删除项目内的子文件夹(需为空:无子文件夹、无文档);项目根文件夹不可删除。
|
||||
func (c *BackendCrmProjectController) DocFolderDelete() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
proj, cid, ok := c.projectDocContext(claims)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var p struct {
|
||||
FolderID uint64 `json:"folder_id"`
|
||||
}
|
||||
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||
if err := json.Unmarshal(raw, &p); err != nil || p.FolderID == 0 {
|
||||
pipelineErr(&c.Controller, 400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
if p.FolderID == cid {
|
||||
pipelineErr(&c.Controller, 400, 400, "项目根文件夹不可删除")
|
||||
return
|
||||
}
|
||||
actor := services.NewOaDocActor(claims.TenantId, uint64(claims.UserID))
|
||||
if !services.CrmProjectDocCategoryInScope(actor, cid, p.FolderID) {
|
||||
pipelineErr(&c.Controller, 403, 403, "该文件夹不属于当前项目")
|
||||
return
|
||||
}
|
||||
var folder models.OaDocCategory
|
||||
if err := models.Orm.QueryTable(new(models.OaDocCategory)).
|
||||
Filter("tid", claims.TenantId).Filter("id", p.FolderID).
|
||||
Filter("is_deleted", 0).One(&folder); err != nil {
|
||||
pipelineErr(&c.Controller, 404, 404, "文件夹不存在")
|
||||
return
|
||||
}
|
||||
if err := services.OaDocCategoryDelete(actor, p.FolderID); err != nil {
|
||||
pipelineErr(&c.Controller, 400, 400, err.Error())
|
||||
return
|
||||
}
|
||||
crmWriteLog(pipelineTenantID(claims), 3, proj.ID, "doc-folder", "删除项目文档文件夹:"+folder.Name, claims)
|
||||
pipelineOk(&c.Controller, nil)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package controllers
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -39,6 +41,39 @@ func (c *BackendCrmFollowController) List() {
|
||||
relatedID := strings.TrimSpace(c.GetString("related_id"))
|
||||
followType := strings.TrimSpace(c.GetString("follow_type"))
|
||||
keyword := strings.TrimSpace(c.GetString("keyword"))
|
||||
includeSource := strings.TrimSpace(c.GetString("include_source")) == "1"
|
||||
|
||||
// 详情页链路模式(include_source=1):按 线索→商机→项目 累积返回上游 + 自身的数据
|
||||
if includeSource && relatedType != "" && relatedID != "" && relatedID != "0" {
|
||||
rid, _ := strconv.ParseUint(relatedID, 10, 64)
|
||||
rt, _ := strconv.Atoi(relatedType)
|
||||
refs := relatedChain(tenantID, int8(rt), rid)
|
||||
var merged []models.TenantCrmFollow
|
||||
for _, r := range refs {
|
||||
var rows []models.TenantCrmFollow
|
||||
sub := orm.NewCondition().
|
||||
And("tenant_id", tenantID).
|
||||
And("delete_time__isnull", true).
|
||||
And("related_type", r.Type).
|
||||
And("related_id", r.ID)
|
||||
_, _ = models.Orm.QueryTable(new(models.TenantCrmFollow)).SetCond(sub).All(&rows)
|
||||
merged = append(merged, rows...)
|
||||
}
|
||||
sort.Slice(merged, func(i, j int) bool {
|
||||
ti, tj := merged[i].FollowTime, merged[j].FollowTime
|
||||
if ti != nil && tj != nil && !ti.Equal(*tj) {
|
||||
return ti.After(*tj)
|
||||
}
|
||||
if (ti == nil) != (tj == nil) {
|
||||
return ti != nil
|
||||
}
|
||||
return merged[i].ID > merged[j].ID
|
||||
})
|
||||
pipelineOk(&c.Controller, map[string]interface{}{
|
||||
"list": merged, "total": len(merged), "page": 1, "pageSize": len(merged),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
|
||||
if relatedType != "" {
|
||||
@@ -72,6 +107,7 @@ type followPayload struct {
|
||||
RelatedType int8 `json:"related_type"`
|
||||
RelatedID uint64 `json:"related_id"`
|
||||
RelatedName string `json:"related_name"`
|
||||
Stage int8 `json:"stage"` // 创建阶段:不传时默认取 related_type
|
||||
FollowType string `json:"follow_type"`
|
||||
FollowTime string `json:"follow_time"`
|
||||
Content string `json:"content"`
|
||||
@@ -106,17 +142,23 @@ func (c *BackendCrmFollowController) Add() {
|
||||
followTime = &t
|
||||
}
|
||||
nextContact := parsePipelineDateTime(p.NextContactTime)
|
||||
// 创建阶段:默认取当前关联类型(线索/商机/项目)
|
||||
stage := p.Stage
|
||||
if stage != 1 && stage != 2 && stage != 3 {
|
||||
stage = p.RelatedType
|
||||
}
|
||||
row := models.TenantCrmFollow{
|
||||
TenantID: tenantID,
|
||||
RelatedType: p.RelatedType,
|
||||
RelatedID: p.RelatedID,
|
||||
RelatedName: strings.TrimSpace(p.RelatedName),
|
||||
Stage: stage,
|
||||
FollowType: firstNonEmpty(p.FollowType, "1"),
|
||||
FollowTime: followTime,
|
||||
Content: p.Content,
|
||||
NextContactTime: nextContact,
|
||||
OwnerUserID: pipelineUID(claims),
|
||||
OwnerUserName: claims.Username,
|
||||
OwnerUserName: resolveUserName(claims),
|
||||
CreateTime: time.Now(),
|
||||
UpdateTime: time.Now(),
|
||||
}
|
||||
@@ -159,6 +201,11 @@ func (c *BackendCrmFollowController) Edit() {
|
||||
return
|
||||
}
|
||||
row.FollowType = firstNonEmpty(p.FollowType, row.FollowType)
|
||||
// 回访阶段(所属阶段)允许修正:仅在传入合法阶段(1线索/2商机/3项目)时更新,
|
||||
// 保证"记录创建时所在阶段"可被纠正为沟通实际发生的阶段。
|
||||
if p.Stage == 1 || p.Stage == 2 || p.Stage == 3 {
|
||||
row.Stage = p.Stage
|
||||
}
|
||||
if t := parsePipelineDateTime(p.FollowTime); t != nil {
|
||||
row.FollowTime = t
|
||||
}
|
||||
@@ -237,17 +284,35 @@ func (c *BackendCrmAttachController) List() {
|
||||
}
|
||||
relatedType := strings.TrimSpace(c.GetString("related_type"))
|
||||
relatedID := strings.TrimSpace(c.GetString("related_id"))
|
||||
cond := orm.NewCondition().
|
||||
And("tenant_id", pipelineTenantID(claims)).
|
||||
And("delete_time__isnull", true)
|
||||
if relatedType != "" {
|
||||
cond = cond.And("related_type", relatedType)
|
||||
}
|
||||
if relatedID != "" {
|
||||
cond = cond.And("related_id", relatedID)
|
||||
}
|
||||
includeSource := strings.TrimSpace(c.GetString("include_source")) == "1"
|
||||
tenantID := pipelineTenantID(claims)
|
||||
|
||||
var list []models.TenantCrmAttach
|
||||
_, _ = models.Orm.QueryTable(new(models.TenantCrmAttach)).SetCond(cond).OrderBy("-id").All(&list)
|
||||
if includeSource && relatedType != "" && relatedID != "" && relatedID != "0" {
|
||||
// 链路模式:线索 → 商机 → 项目 累积
|
||||
rid, _ := strconv.ParseUint(relatedID, 10, 64)
|
||||
rt, _ := strconv.Atoi(relatedType)
|
||||
for _, r := range relatedChain(tenantID, int8(rt), rid) {
|
||||
var rows []models.TenantCrmAttach
|
||||
sub := orm.NewCondition().
|
||||
And("tenant_id", tenantID).
|
||||
And("delete_time__isnull", true).
|
||||
And("related_type", r.Type).
|
||||
And("related_id", r.ID)
|
||||
_, _ = models.Orm.QueryTable(new(models.TenantCrmAttach)).SetCond(sub).All(&rows)
|
||||
list = append(list, rows...)
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool { return list[i].ID > list[j].ID })
|
||||
} else {
|
||||
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
|
||||
if relatedType != "" {
|
||||
cond = cond.And("related_type", relatedType)
|
||||
}
|
||||
if relatedID != "" {
|
||||
cond = cond.And("related_id", relatedID)
|
||||
}
|
||||
_, _ = models.Orm.QueryTable(new(models.TenantCrmAttach)).SetCond(cond).OrderBy("-id").All(&list)
|
||||
}
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"list": list, "total": len(list)})
|
||||
}
|
||||
|
||||
@@ -290,7 +355,7 @@ func (c *BackendCrmAttachController) Add() {
|
||||
FileURL: strings.TrimSpace(p.FileURL),
|
||||
FileSize: p.FileSize,
|
||||
UploaderID: pipelineUID(claims),
|
||||
UploaderName: claims.Username,
|
||||
UploaderName: resolveUserName(claims),
|
||||
CreateTime: time.Now(),
|
||||
UpdateTime: time.Now(),
|
||||
}
|
||||
@@ -350,18 +415,41 @@ func (c *BackendCrmEntityContactController) List() {
|
||||
}
|
||||
relatedType := strings.TrimSpace(c.GetString("related_type"))
|
||||
relatedID := strings.TrimSpace(c.GetString("related_id"))
|
||||
cond := orm.NewCondition().
|
||||
And("tenant_id", pipelineTenantID(claims)).
|
||||
And("delete_time__isnull", true)
|
||||
if relatedType != "" {
|
||||
cond = cond.And("related_type", relatedType)
|
||||
}
|
||||
if relatedID != "" {
|
||||
cond = cond.And("related_id", relatedID)
|
||||
}
|
||||
includeSource := strings.TrimSpace(c.GetString("include_source")) == "1"
|
||||
tenantID := pipelineTenantID(claims)
|
||||
|
||||
var list []models.TenantCrmEntityContact
|
||||
_, _ = models.Orm.QueryTable(new(models.TenantCrmEntityContact)).SetCond(cond).
|
||||
OrderBy("-is_primary", "-id").All(&list)
|
||||
if includeSource && relatedType != "" && relatedID != "" && relatedID != "0" {
|
||||
// 链路模式:线索 → 商机 → 项目 累积
|
||||
rid, _ := strconv.ParseUint(relatedID, 10, 64)
|
||||
rt, _ := strconv.Atoi(relatedType)
|
||||
for _, r := range relatedChain(tenantID, int8(rt), rid) {
|
||||
var rows []models.TenantCrmEntityContact
|
||||
sub := orm.NewCondition().
|
||||
And("tenant_id", tenantID).
|
||||
And("delete_time__isnull", true).
|
||||
And("related_type", r.Type).
|
||||
And("related_id", r.ID)
|
||||
_, _ = models.Orm.QueryTable(new(models.TenantCrmEntityContact)).SetCond(sub).All(&rows)
|
||||
list = append(list, rows...)
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
if list[i].IsPrimary != list[j].IsPrimary {
|
||||
return list[i].IsPrimary > list[j].IsPrimary
|
||||
}
|
||||
return list[i].ID > list[j].ID
|
||||
})
|
||||
} else {
|
||||
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
|
||||
if relatedType != "" {
|
||||
cond = cond.And("related_type", relatedType)
|
||||
}
|
||||
if relatedID != "" {
|
||||
cond = cond.And("related_id", relatedID)
|
||||
}
|
||||
_, _ = models.Orm.QueryTable(new(models.TenantCrmEntityContact)).SetCond(cond).
|
||||
OrderBy("-is_primary", "-id").All(&list)
|
||||
}
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"list": list, "total": len(list)})
|
||||
}
|
||||
|
||||
|
||||
@@ -227,7 +227,7 @@ func (c *BackendErpCustomerController) Create() {
|
||||
RegisteredAddress: strings.TrimSpace(p.RegisteredAddress),
|
||||
RegisteredPhone: strings.TrimSpace(p.RegisteredPhone),
|
||||
OwnerUserID: fmt.Sprintf("%d", claims.UserID),
|
||||
OwnerUserName: claims.Username,
|
||||
OwnerUserName: resolveUserName(claims),
|
||||
CreateUserID: fmt.Sprintf("%d", claims.UserID),
|
||||
CreateTime: time.Now(),
|
||||
UpdateTime: time.Now(),
|
||||
|
||||
@@ -141,6 +141,15 @@ func Init(_ string) {
|
||||
EnsureTenantUserOrgColumn()
|
||||
EnsureCrmCustomerPoolColumns()
|
||||
EnsureCrmCreateUserColumn()
|
||||
EnsureCrmProjectDocColumn()
|
||||
}
|
||||
|
||||
// EnsureCrmProjectDocColumn 补齐项目表的文档库文件夹分类字段(项目文档绑定 OA 文档库)。
|
||||
// 表不存在或列已存在时忽略错误。
|
||||
func EnsureCrmProjectDocColumn() {
|
||||
sql := "ALTER TABLE " + new(TenantCrmProject).TableName() +
|
||||
" ADD COLUMN doc_category_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '关联OA文档库项目文件夹分类ID'"
|
||||
_, _ = Orm.Raw(sql).Exec()
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ package models
|
||||
|
||||
import "time"
|
||||
|
||||
// TenantCrmAttach 附件表: yz_tenant_crm_attach
|
||||
// TenantCrmAttach 附件表: yz_backend_crm_attach
|
||||
// related_type: 1线索 / 2商机 / 3项目
|
||||
type TenantCrmAttach struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
@@ -21,5 +21,5 @@ type TenantCrmAttach struct {
|
||||
}
|
||||
|
||||
func (m *TenantCrmAttach) TableName() string {
|
||||
return "yz_tenant_crm_attach"
|
||||
return "yz_backend_crm_attach"
|
||||
}
|
||||
|
||||
@@ -2,12 +2,13 @@ package models
|
||||
|
||||
import "time"
|
||||
|
||||
// TenantCrmBusiness 商机表: yz_tenant_crm_business
|
||||
// TenantCrmBusiness 商机表: yz_backend_crm_business
|
||||
type TenantCrmBusiness struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
|
||||
BusinessName string `orm:"column(business_name);size(100)" json:"business_name"` // 商机名称
|
||||
ClueID *uint64 `orm:"column(clue_id);null" json:"clue_id"` // 来源线索ID
|
||||
ClueName string `orm:"-" json:"clue_name"` // 来源线索名称(非表字段,查询时填充)
|
||||
CustomerID *uint64 `orm:"column(customer_id);null" json:"customer_id"` // 正式客户ID
|
||||
CustomerName string `orm:"column(customer_name);size(100)" json:"customer_name"`
|
||||
Source string `orm:"column(source);size(50)" json:"source"`
|
||||
@@ -37,5 +38,5 @@ type TenantCrmBusiness struct {
|
||||
}
|
||||
|
||||
func (m *TenantCrmBusiness) TableName() string {
|
||||
return "yz_tenant_crm_business"
|
||||
return "yz_backend_crm_business"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package models
|
||||
|
||||
import "time"
|
||||
|
||||
// TenantCrmClue 线索表: yz_tenant_crm_clue
|
||||
// TenantCrmClue 线索表: yz_backend_crm_clue
|
||||
// 线索阶段的「客户名称」不关联客户管理,转化商机时才落地为正式客户。
|
||||
type TenantCrmClue struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
@@ -33,5 +33,5 @@ type TenantCrmClue struct {
|
||||
}
|
||||
|
||||
func (m *TenantCrmClue) TableName() string {
|
||||
return "yz_tenant_crm_clue"
|
||||
return "yz_backend_crm_clue"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package models
|
||||
|
||||
import "time"
|
||||
|
||||
// TenantCrmEntityContact 联系人表: yz_tenant_crm_entity_contact
|
||||
// TenantCrmEntityContact 联系人表: yz_backend_crm_entity_contact
|
||||
// related_type: 1线索 / 2商机 / 3项目
|
||||
type TenantCrmEntityContact struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
@@ -23,5 +23,5 @@ type TenantCrmEntityContact struct {
|
||||
}
|
||||
|
||||
func (m *TenantCrmEntityContact) TableName() string {
|
||||
return "yz_tenant_crm_entity_contact"
|
||||
return "yz_backend_crm_entity_contact"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package models
|
||||
|
||||
import "time"
|
||||
|
||||
// TenantCrmFollow 回访/跟进记录表: yz_tenant_crm_follow
|
||||
// TenantCrmFollow 回访/跟进记录表: yz_backend_crm_follow
|
||||
// related_type: 1线索 / 2商机 / 3项目
|
||||
type TenantCrmFollow struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
@@ -10,6 +10,7 @@ type TenantCrmFollow struct {
|
||||
RelatedType int8 `orm:"column(related_type)" json:"related_type"`
|
||||
RelatedID uint64 `orm:"column(related_id)" json:"related_id"`
|
||||
RelatedName string `orm:"column(related_name);size(100)" json:"related_name"`
|
||||
Stage int8 `orm:"column(stage);default(1)" json:"stage"` // 创建阶段:1线索/2商机/3项目
|
||||
FollowType string `orm:"column(follow_type);size(20);default(1)" json:"follow_type"` // 回访方式
|
||||
FollowTime *time.Time `orm:"column(follow_time);type(datetime);null" json:"follow_time"`
|
||||
Content string `orm:"column(content);type(text);null" json:"content"`
|
||||
@@ -22,5 +23,5 @@ type TenantCrmFollow struct {
|
||||
}
|
||||
|
||||
func (m *TenantCrmFollow) TableName() string {
|
||||
return "yz_tenant_crm_follow"
|
||||
return "yz_backend_crm_follow"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package models
|
||||
|
||||
import "time"
|
||||
|
||||
// TenantCrmOperateLog 操作日志表: yz_tenant_crm_operate_log
|
||||
// TenantCrmOperateLog 操作日志表: yz_backend_crm_operate_log
|
||||
// related_type: 1线索 / 2商机 / 3项目
|
||||
type TenantCrmOperateLog struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
@@ -17,5 +17,5 @@ type TenantCrmOperateLog struct {
|
||||
}
|
||||
|
||||
func (m *TenantCrmOperateLog) TableName() string {
|
||||
return "yz_tenant_crm_operate_log"
|
||||
return "yz_backend_crm_operate_log"
|
||||
}
|
||||
|
||||
@@ -2,13 +2,14 @@ package models
|
||||
|
||||
import "time"
|
||||
|
||||
// TenantCrmProject 项目表: yz_tenant_crm_project
|
||||
// TenantCrmProject 项目表: yz_backend_crm_project
|
||||
type TenantCrmProject struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
|
||||
ProjectName string `orm:"column(project_name);size(100)" json:"project_name"` // 项目名称
|
||||
ProjectNo string `orm:"column(project_no);size(50)" json:"project_no"` // 项目编号
|
||||
BusinessID *uint64 `orm:"column(business_id);null" json:"business_id"` // 来源商机ID
|
||||
BusinessName string `orm:"-" json:"business_name"` // 来源商机名称(非表字段,查询时填充)
|
||||
CustomerID *uint64 `orm:"column(customer_id);null" json:"customer_id"`
|
||||
CustomerName string `orm:"column(customer_name);size(100)" json:"customer_name"`
|
||||
OwnerUserID string `orm:"column(owner_user_id);size(64)" json:"owner_user_id"`
|
||||
@@ -23,6 +24,9 @@ type TenantCrmProject struct {
|
||||
ContactPhone string `orm:"column(contact_phone);size(20)" json:"contact_phone"`
|
||||
Address string `orm:"column(address);size(255)" json:"address"`
|
||||
Remark string `orm:"column(remark);type(text);null" json:"remark"`
|
||||
// DocCategoryID 项目在 OA 文档库的文件夹分类ID:
|
||||
// 指向 yz_backend_oa_doc_category 中「共享文档 / 项目文档 / {项目名称}」这条记录(0 表示尚未建立)。
|
||||
DocCategoryID uint64 `orm:"column(doc_category_id);default(0)" json:"doc_category_id"`
|
||||
CreateUserID string `orm:"column(create_user_id);size(64)" json:"create_user_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"`
|
||||
@@ -30,5 +34,5 @@ type TenantCrmProject struct {
|
||||
}
|
||||
|
||||
func (m *TenantCrmProject) TableName() string {
|
||||
return "yz_tenant_crm_project"
|
||||
return "yz_backend_crm_project"
|
||||
}
|
||||
|
||||
@@ -401,6 +401,13 @@ func registerOrganizationRoutes(module string) {
|
||||
beego.Router("/backend/crm/project", &controllers.BackendCrmProjectController{}, "post:Create")
|
||||
beego.Router("/backend/crm/project/:id", &controllers.BackendCrmProjectController{}, "get:Detail;put:Update;delete:Delete")
|
||||
|
||||
// CRM项目文档(与OA文档库「共享文档/项目文档」绑定:列表/上传同步/删除同步/文件夹管理)
|
||||
beego.Router("/backend/crm/project/:id/docs", &controllers.BackendCrmProjectController{}, "get:DocList")
|
||||
beego.Router("/backend/crm/project/:id/doc-upload", &controllers.BackendCrmProjectController{}, "post:DocUpload")
|
||||
beego.Router("/backend/crm/project/:id/doc-delete", &controllers.BackendCrmProjectController{}, "post:DocDelete")
|
||||
beego.Router("/backend/crm/project/:id/doc-folder", &controllers.BackendCrmProjectController{}, "post:DocFolderCreate")
|
||||
beego.Router("/backend/crm/project/:id/doc-folder-delete", &controllers.BackendCrmProjectController{}, "post:DocFolderDelete")
|
||||
|
||||
// CRM回访记录(贯穿线索/商机/项目)
|
||||
beego.Router("/backend/crm/follow/list", &controllers.BackendCrmFollowController{}, "get:List")
|
||||
beego.Router("/backend/crm/follow/add", &controllers.BackendCrmFollowController{}, "post:Add")
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
)
|
||||
|
||||
// 项目文档(CRM 项目 ↔ OA 文档库)绑定业务逻辑。
|
||||
//
|
||||
// 约定:每个项目在文档库「共享文档 / 项目文档」下拥有一个与项目同名的文件夹,
|
||||
// 项目管理侧负责调用与文件上传,文件统一写入文档库(yz_backend_oa_doc),
|
||||
// 文档库侧负责浏览、图谱与共享。项目表用 doc_category_id 记住自己文件夹的分类ID,
|
||||
// 项目改名时同步重命名文件夹;存量项目在首次访问项目文档时自动补建。
|
||||
|
||||
// EnsureCrmProjectDocCategory 确保项目在文档库「共享文档 / 项目文档」下拥有同名文件夹,返回文件夹分类ID。
|
||||
//
|
||||
// 兼容存量项目:项目上的分类ID为空(0)或指向已删除分类时自动补建,并回写项目表;
|
||||
// 若「项目文档」下已有同名且未被其它项目占用的文件夹则复用,避免重复建目录。
|
||||
// 失败时返回 error(由调用方决定是否阻断,建议静默忽略,不影响项目主流程)。
|
||||
func EnsureCrmProjectDocCategory(tid int, projectID uint64, projectName string, currentCategoryID uint64) (uint64, error) {
|
||||
if models.Orm == nil || tid <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
projectName = strings.TrimSpace(projectName)
|
||||
if projectName == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
models.EnsureOaDocumentTables()
|
||||
models.EnsureOaDocDefaultCategories(tid)
|
||||
|
||||
// 1. 已绑定且分类仍有效:直接返回
|
||||
if currentCategoryID > 0 {
|
||||
n, err := models.Orm.QueryTable(new(models.OaDocCategory)).
|
||||
Filter("tid", tid).
|
||||
Filter("id", currentCategoryID).
|
||||
Filter("is_deleted", 0).Count()
|
||||
if err == nil && n > 0 {
|
||||
return currentCategoryID, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 定位「项目文档」内置分类(首次访问时已由 EnsureOaDocDefaultCategories 补建)
|
||||
rootID, err := crmProjectDocRootCategory(tid)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// 3. 复用「项目文档」下同名且未被其它项目占用的文件夹(同名项目共存时各用各的)
|
||||
var sameName []models.OaDocCategory
|
||||
if _, err := models.Orm.QueryTable(new(models.OaDocCategory)).
|
||||
Filter("tid", tid).
|
||||
Filter("parent_id", rootID).
|
||||
Filter("scope", models.DocScopeShared).
|
||||
Filter("user_id", 0).
|
||||
Filter("name", projectName).
|
||||
Filter("is_deleted", 0).
|
||||
OrderBy("id").All(&sameName); err != nil && err != orm.ErrNoRows {
|
||||
return 0, err
|
||||
}
|
||||
for _, c := range sameName {
|
||||
bound, err := crmProjectDocCategoryBound(tid, c.ID, projectID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !bound {
|
||||
return c.ID, crmProjectDocBindCategory(tid, projectID, c.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 新建项目同名文件夹
|
||||
item := &models.OaDocCategory{
|
||||
Tid: tid,
|
||||
UserID: 0,
|
||||
ParentID: rootID,
|
||||
Name: projectName,
|
||||
Remark: "项目文档:" + projectName,
|
||||
Sort: 0,
|
||||
IsSystem: models.DocCategoryNormal,
|
||||
Scope: models.DocScopeShared,
|
||||
IsDeleted: 0,
|
||||
}
|
||||
id, err := models.Orm.Insert(item)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
categoryID := uint64(id)
|
||||
return categoryID, crmProjectDocBindCategory(tid, projectID, categoryID)
|
||||
}
|
||||
|
||||
// RenameCrmProjectDocCategory 项目改名时同步重命名文档库中的项目文件夹(失败由调用方处理)。
|
||||
func RenameCrmProjectDocCategory(tid int, categoryID uint64, newName string) error {
|
||||
newName = strings.TrimSpace(newName)
|
||||
if models.Orm == nil || tid <= 0 || categoryID == 0 || newName == "" {
|
||||
return nil
|
||||
}
|
||||
var item models.OaDocCategory
|
||||
if err := models.Orm.QueryTable(new(models.OaDocCategory)).
|
||||
Filter("tid", tid).Filter("id", categoryID).
|
||||
Filter("is_deleted", 0).One(&item); err != nil {
|
||||
return ErrDocCategoryNotFound
|
||||
}
|
||||
if item.Name == newName {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
_, err := models.Orm.QueryTable(new(models.OaDocCategory)).
|
||||
Filter("id", categoryID).Filter("tid", tid).
|
||||
Update(orm.Params{"name": newName, "update_time": now})
|
||||
return err
|
||||
}
|
||||
|
||||
// CrmProjectDocCategoryNameMap 返回租户内未删除分类的 ID -> 名称映射(项目文档列表展示文件夹名用)。
|
||||
func CrmProjectDocCategoryNameMap(tid int) map[uint64]string {
|
||||
result := make(map[uint64]string)
|
||||
if models.Orm == nil || tid <= 0 {
|
||||
return result
|
||||
}
|
||||
var list []models.OaDocCategory
|
||||
if _, err := models.Orm.QueryTable(new(models.OaDocCategory)).
|
||||
Filter("tid", tid).Filter("is_deleted", 0).All(&list); err != nil && err != orm.ErrNoRows {
|
||||
return result
|
||||
}
|
||||
for _, c := range list {
|
||||
result[c.ID] = c.Name
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// CrmProjectDocCategoryInScope 判断分类是否位于项目文件夹内(含项目文件夹自身)。
|
||||
// 用于约束项目侧的文件夹创建、文件上传与删除只能在项目自己的目录下操作。
|
||||
func CrmProjectDocCategoryInScope(a OaDocActor, rootID, categoryID uint64) bool {
|
||||
if rootID == 0 || categoryID == 0 {
|
||||
return false
|
||||
}
|
||||
if categoryID == rootID {
|
||||
return true
|
||||
}
|
||||
children, err := OaDocCategoryDescendants(a, rootID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, id := range children {
|
||||
if id == categoryID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ProjectDocFolder 项目文档文件夹树节点(含该文件夹直接挂载的文档数量)。
|
||||
type ProjectDocFolder struct {
|
||||
ID uint64 `json:"id"`
|
||||
ParentID uint64 `json:"parent_id"`
|
||||
Name string `json:"name"`
|
||||
DocCount int64 `json:"doc_count"`
|
||||
Children []*ProjectDocFolder `json:"children"`
|
||||
}
|
||||
|
||||
// CrmProjectDocFolderTree 构建项目文件夹树(根为项目文件夹,含全部子孙文件夹)。
|
||||
// 「项目文档」下的同名文件夹即项目根,用户在项目内新建的文件夹为其子分类。
|
||||
func CrmProjectDocFolderTree(a OaDocActor, rootID uint64) ([]*ProjectDocFolder, error) {
|
||||
models.EnsureOaDocumentTables()
|
||||
empty := []*ProjectDocFolder{}
|
||||
if rootID == 0 {
|
||||
return empty, nil
|
||||
}
|
||||
var list []models.OaDocCategory
|
||||
if _, err := models.Orm.QueryTable(new(models.OaDocCategory)).
|
||||
Filter("tid", a.Tid).Filter("is_deleted", 0).OrderBy("id").All(&list); err != nil && err != orm.ErrNoRows {
|
||||
return nil, err
|
||||
}
|
||||
nodes := make(map[uint64]*ProjectDocFolder, len(list))
|
||||
for i := range list {
|
||||
// 项目文件夹属于共享空间(scope=shared、user_id=0)
|
||||
if list[i].Scope != models.DocScopeShared || list[i].UserID != 0 {
|
||||
continue
|
||||
}
|
||||
nodes[list[i].ID] = &ProjectDocFolder{
|
||||
ID: list[i].ID,
|
||||
ParentID: list[i].ParentID,
|
||||
Name: list[i].Name,
|
||||
Children: []*ProjectDocFolder{},
|
||||
}
|
||||
}
|
||||
root, ok := nodes[rootID]
|
||||
if !ok {
|
||||
return empty, nil
|
||||
}
|
||||
// 按 id 顺序挂接父子关系;只有能与根连通的节点才会出现在项目子树中
|
||||
for i := range list {
|
||||
node, ok := nodes[list[i].ID]
|
||||
if !ok || node.ID == rootID {
|
||||
continue
|
||||
}
|
||||
if parent, ok := nodes[list[i].ParentID]; ok {
|
||||
parent.Children = append(parent.Children, node)
|
||||
}
|
||||
}
|
||||
// 附带各文件夹直接挂载的文档数量(仅展示用,不汇总子孙)
|
||||
if counts, err := OaDocCountByCategory(a, models.DocScopeShared, -1); err == nil {
|
||||
for id, cnt := range counts {
|
||||
if n, ok := nodes[id]; ok {
|
||||
n.DocCount = cnt
|
||||
}
|
||||
}
|
||||
}
|
||||
return []*ProjectDocFolder{root}, nil
|
||||
}
|
||||
|
||||
// crmProjectDocRootCategory 查找租户「项目文档」内置分类;缺失时兜底重建(正常由文档库初始化保证存在)。
|
||||
func crmProjectDocRootCategory(tid int) (uint64, error) {
|
||||
var root models.OaDocCategory
|
||||
err := models.Orm.QueryTable(new(models.OaDocCategory)).
|
||||
Filter("tid", tid).
|
||||
Filter("scope", models.DocScopeShared).
|
||||
Filter("is_system", models.DocCategorySystem).
|
||||
Filter("is_deleted", 0).
|
||||
OrderBy("id").One(&root)
|
||||
if err == nil {
|
||||
return root.ID, nil
|
||||
}
|
||||
id, insertErr := models.Orm.Insert(&models.OaDocCategory{
|
||||
Tid: tid,
|
||||
UserID: 0,
|
||||
ParentID: 0,
|
||||
Name: models.DocCategoryNameProject,
|
||||
Sort: 0,
|
||||
Remark: "系统内置分类,不可删除",
|
||||
IsSystem: models.DocCategorySystem,
|
||||
Scope: models.DocScopeShared,
|
||||
IsDeleted: 0,
|
||||
})
|
||||
if insertErr != nil {
|
||||
return 0, insertErr
|
||||
}
|
||||
return uint64(id), nil
|
||||
}
|
||||
|
||||
// crmProjectDocCategoryBound 判断分类是否已被同租户的其它项目占用(excludeProjectID 为当前项目)。
|
||||
func crmProjectDocCategoryBound(tid int, categoryID, excludeProjectID uint64) (bool, error) {
|
||||
qs := models.Orm.QueryTable(new(models.TenantCrmProject)).
|
||||
Filter("tenant_id", strconv.Itoa(tid)).
|
||||
Filter("doc_category_id", categoryID).
|
||||
Filter("delete_time__isnull", true)
|
||||
if excludeProjectID > 0 {
|
||||
qs = qs.Exclude("id", excludeProjectID)
|
||||
}
|
||||
n, err := qs.Count()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// crmProjectDocBindCategory 回写项目与文档分类的绑定关系(projectID=0 时跳过)。
|
||||
func crmProjectDocBindCategory(tid int, projectID, categoryID uint64) error {
|
||||
if projectID == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := models.Orm.QueryTable(new(models.TenantCrmProject)).
|
||||
Filter("id", projectID).
|
||||
Filter("tenant_id", strconv.Itoa(tid)).
|
||||
Update(orm.Params{"doc_category_id": categoryID, "update_time": time.Now()})
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user