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" ) // BackendCrmProjectController CRM 项目管理 type BackendCrmProjectController struct { beego.Controller } type projectPayload struct { ProjectName string `json:"project_name"` ProjectNo string `json:"project_no"` CustomerID uint64 `json:"customer_id"` CustomerName string `json:"customer_name"` OwnerUserID string `json:"owner_user_id"` OwnerUserName string `json:"owner_user_name"` Industry string `json:"industry"` Amount float64 `json:"amount"` Status int8 `json:"status"` StartDate string `json:"start_date"` EndDate string `json:"end_date"` ContactPerson string `json:"contact_person"` ContactPosition string `json:"contact_position"` ContactPhone string `json:"contact_phone"` Address string `json:"address"` Remark string `json:"remark"` } // List GET /backend/crm/project/list func (c *BackendCrmProjectController) List() { claims, err := pipelineClaims(&c.Controller) if err != nil { pipelineErr(&c.Controller, 401, 401, err.Error()) return } page, _ := c.GetInt("page", 1) pageSize, _ := c.GetInt("pageSize", 20) if page < 1 { page = 1 } if pageSize < 1 || pageSize > 100 { pageSize = 20 } keyword := strings.TrimSpace(c.GetString("keyword")) status := strings.TrimSpace(c.GetString("status")) ownerID := strings.TrimSpace(c.GetString("owner_user_id")) tenantID := pipelineTenantID(claims) cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true) if keyword != "" { kw := orm.NewCondition(). Or("project_name__contains", keyword). Or("project_no__contains", keyword). Or("customer_name__contains", keyword) cond = cond.AndCond(kw) } if status != "" { cond = cond.And("status", status) } if ownerID != "" { cond = cond.And("owner_user_id", ownerID) } qs := models.Orm.QueryTable(new(models.TenantCrmProject)).SetCond(cond) total, _ := qs.Count() var list []models.TenantCrmProject 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, }) } // Detail GET /backend/crm/project/:id func (c *BackendCrmProjectController) Detail() { claims, err := pipelineClaims(&c.Controller) if err != nil { pipelineErr(&c.Controller, 401, 401, err.Error()) return } id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) if id == 0 { pipelineErr(&c.Controller, 400, 400, "无效的ID") return } var proj models.TenantCrmProject err = models.Orm.QueryTable(new(models.TenantCrmProject)). Filter("id", id).Filter("tenant_id", pipelineTenantID(claims)). Filter("delete_time__isnull", true).One(&proj) if err != nil { 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) } // Create POST /backend/crm/project func (c *BackendCrmProjectController) Create() { claims, err := pipelineClaims(&c.Controller) if err != nil { pipelineErr(&c.Controller, 401, 401, err.Error()) return } raw, _ := io.ReadAll(c.Ctx.Request.Body) var p projectPayload if err := json.Unmarshal(raw, &p); err != nil { pipelineErr(&c.Controller, 400, 400, "参数错误") return } if strings.TrimSpace(p.ProjectName) == "" { pipelineErr(&c.Controller, 400, 400, "项目名称不能为空") return } tenantID := pipelineTenantID(claims) var custIDVal *uint64 customerName := strings.TrimSpace(p.CustomerName) if p.CustomerID > 0 { var cust models.TenantCrmCustomer if err := models.Orm.QueryTable(new(models.TenantCrmCustomer)). Filter("id", p.CustomerID).Filter("tenant_id", tenantID). Filter("delete_time__isnull", true).One(&cust); err == nil { cid := cust.ID custIDVal = &cid customerName = cust.CustomerName } } status := p.Status if status == 0 { status = 1 } now := time.Now() proj := models.TenantCrmProject{ TenantID: tenantID, ProjectName: strings.TrimSpace(p.ProjectName), ProjectNo: strings.TrimSpace(p.ProjectNo), CustomerID: custIDVal, CustomerName: customerName, OwnerUserID: firstNonEmpty(p.OwnerUserID, pipelineUID(claims)), OwnerUserName: firstNonEmpty(p.OwnerUserName, resolveUserName(claims)), Industry: strings.TrimSpace(p.Industry), Amount: p.Amount, Status: status, StartDate: parsePipelineDate(p.StartDate), EndDate: parsePipelineDate(p.EndDate), ContactPerson: strings.TrimSpace(p.ContactPerson), ContactPosition: strings.TrimSpace(p.ContactPosition), ContactPhone: strings.TrimSpace(p.ContactPhone), Address: strings.TrimSpace(p.Address), Remark: p.Remark, CreateUserID: pipelineUID(claims), CreateTime: now, UpdateTime: now, } id, err := models.Orm.Insert(&proj) if err != nil { 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, "doc_category_id": proj.DocCategoryID}) } // Update PUT /backend/crm/project/:id func (c *BackendCrmProjectController) Update() { claims, err := pipelineClaims(&c.Controller) if err != nil { pipelineErr(&c.Controller, 401, 401, err.Error()) return } id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) if id == 0 { pipelineErr(&c.Controller, 400, 400, "无效的ID") return } raw, _ := io.ReadAll(c.Ctx.Request.Body) var p projectPayload if err := json.Unmarshal(raw, &p); err != nil { pipelineErr(&c.Controller, 400, 400, "参数错误") return } tenantID := pipelineTenantID(claims) var proj models.TenantCrmProject if err := models.Orm.QueryTable(new(models.TenantCrmProject)). Filter("id", id).Filter("tenant_id", tenantID). Filter("delete_time__isnull", true).One(&proj); err != nil { pipelineErr(&c.Controller, 404, 404, "项目未找到") return } if strings.TrimSpace(p.ProjectName) == "" { pipelineErr(&c.Controller, 400, 400, "项目名称不能为空") return } proj.ProjectName = strings.TrimSpace(p.ProjectName) proj.ProjectNo = strings.TrimSpace(p.ProjectNo) if p.CustomerID > 0 { var cust models.TenantCrmCustomer if err := models.Orm.QueryTable(new(models.TenantCrmCustomer)). Filter("id", p.CustomerID).Filter("tenant_id", tenantID). Filter("delete_time__isnull", true).One(&cust); err == nil { cid := cust.ID proj.CustomerID = &cid proj.CustomerName = cust.CustomerName } } else if strings.TrimSpace(p.CustomerName) != "" { proj.CustomerName = strings.TrimSpace(p.CustomerName) } if strings.TrimSpace(p.OwnerUserID) != "" { proj.OwnerUserID = strings.TrimSpace(p.OwnerUserID) } if strings.TrimSpace(p.OwnerUserName) != "" { proj.OwnerUserName = strings.TrimSpace(p.OwnerUserName) } proj.Industry = strings.TrimSpace(p.Industry) proj.Amount = p.Amount if p.Status != 0 { proj.Status = p.Status } proj.StartDate = parsePipelineDate(p.StartDate) proj.EndDate = parsePipelineDate(p.EndDate) proj.ContactPerson = strings.TrimSpace(p.ContactPerson) proj.ContactPosition = strings.TrimSpace(p.ContactPosition) proj.ContactPhone = strings.TrimSpace(p.ContactPhone) proj.Address = strings.TrimSpace(p.Address) proj.Remark = p.Remark proj.UpdateTime = time.Now() if _, err := models.Orm.Update(&proj); err != nil { 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}) } // Delete DELETE /backend/crm/project/:id func (c *BackendCrmProjectController) Delete() { claims, err := pipelineClaims(&c.Controller) if err != nil { pipelineErr(&c.Controller, 401, 401, err.Error()) return } id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) if id == 0 { pipelineErr(&c.Controller, 400, 400, "无效的ID") return } tenantID := pipelineTenantID(claims) var proj models.TenantCrmProject if err := models.Orm.QueryTable(new(models.TenantCrmProject)). Filter("id", id).Filter("tenant_id", tenantID). Filter("delete_time__isnull", true).One(&proj); err != nil { pipelineErr(&c.Controller, 404, 404, "项目未找到") return } if !canDeleteCrmRecord(claims, proj.CreateUserID) { pipelineErr(&c.Controller, 403, 403, "只有创建人、租户管理员或平台管理员可以删除该项目") return } now := time.Now() _, err = models.Orm.QueryTable(new(models.TenantCrmProject)). Filter("id", id).Filter("tenant_id", tenantID). Update(map[string]interface{}{"delete_time": now, "update_time": now}) if err != nil { pipelineErr(&c.Controller, 500, 500, "删除失败: "+err.Error()) return } 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) }