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.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) { 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 }