优化线索、商机、项目相关开发

This commit is contained in:
2026-09-11 17:00:23 +08:00
parent 9c07af2b59
commit 840673bda5
55 changed files with 4866 additions and 625 deletions
+271
View File
@@ -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
}