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 } // crmProjectStatusDone 项目状态「已完成」(1未开始 / 2进行中 / 3已完成 / 4已暂停 / 5异常): // 列表排列时「已完成」沉底展示,与前端筛选传参一致使用字符串值。 const crmProjectStatusDone = "3" // projectStatusName 项目状态文案(用于操作日志)。 func projectStatusName(s int8) string { switch s { case 1: return "未开始" case 2: return "进行中" case 3: return "已完成" case 4: return "已暂停" case 5: return "异常" } return "未知" } type projectPayload struct { ProjectName string `json:"project_name"` ProjectNo string `json:"project_no"` // 项目类型:1=单一项目(仅上游客户) 2=上下游项目(上游客户 + 下游供应商) // 3=上中游项目(上游 A 公司 + 中游 B 公司,下游固定为本公司) ProjectType int8 `json:"project_type"` CustomerID uint64 `json:"customer_id"` CustomerName string `json:"customer_name"` SupplierID uint64 `json:"supplier_id"` SupplierRefType int8 `json:"supplier_ref_type"` // 对方主体来源:1客户 2供应商(下游 / 中游) SupplierName string `json:"supplier_name"` SupplierIndustry string `json:"supplier_industry"` OwnerUserID string `json:"owner_user_id"` OwnerUserName string `json:"owner_user_name"` Industry string `json:"industry"` // 金额:上游=对客户收入,下游=对供应商成本 UpstreamAmount float64 `json:"upstream_amount"` DownstreamAmount float64 `json:"downstream_amount"` // Amount 为旧「项目金额」,未传上游金额时回退使用(兼容未升级的调用方) Amount float64 `json:"amount"` Status int8 `json:"status"` StartDate string `json:"start_date"` EndDate string `json:"end_date"` // 上下游对接人:仅姓名 + 电话;Contact* 为旧字段,未传新字段时回退使用 UpstreamContact string `json:"upstream_contact"` UpstreamPhone string `json:"upstream_phone"` DownstreamContact string `json:"downstream_contact"` DownstreamPhone string `json:"downstream_phone"` ContactPerson string `json:"contact_person"` ContactPosition string `json:"contact_position"` ContactPhone string `json:"contact_phone"` Address string `json:"address"` Remark string `json:"remark"` Products string `json:"products"` // 项目产品清单JSON(生成时写入产品管理) } // normalizeProjectFields 归一项目类型、上下游金额与上下游对接人: // - 项目类型仅识别 2=上下游、3=上中游,其余一律按 1=单一项目处理; // - 上游金额 / 上游对接人缺省时回退旧的 amount / contact_* 字段(兼容未升级的调用方); // - 单一项目不存在上下游,下游金额与下游对接人一并清空; // - 3=上中游项目(上游 A 公司 + 中游 B 公司,下游固定为本公司)的中游主体复用下游字段存储。 func normalizeProjectFields(p *projectPayload) (pt int8, upstream, downstream float64, upContact, upPhone, downContact, downPhone string) { pt = p.ProjectType if pt != 2 && pt != 3 { pt = 1 } upstream = p.UpstreamAmount if upstream == 0 && p.Amount != 0 { upstream = p.Amount } upContact = strings.TrimSpace(firstNonEmpty(p.UpstreamContact, p.ContactPerson)) upPhone = strings.TrimSpace(firstNonEmpty(p.UpstreamPhone, p.ContactPhone)) // 上下游项目:对方为下游(供应商/客户);上中游项目:对方为中游(中间方)——两者均落在 downstream_* 字段 if pt == 2 || pt == 3 { downstream = p.DownstreamAmount downContact = strings.TrimSpace(p.DownstreamContact) downPhone = strings.TrimSpace(p.DownstreamPhone) } return } // resolveProjectSupplier 解析项目下游主体: // refType=2 关联供应商库,refType=1 关联客户库(同一家单位可能以客户身份建档); // 命中时以库中的 ID 与标准名称为准,未命中(或未选择库记录)时保留手工填写的名称、不保留关联。 func resolveProjectSupplier(tenantID string, refType int8, refID uint64, name string) (*uint64, int8, string) { name = strings.TrimSpace(name) if refID == 0 { return nil, 0, name } switch refType { case 2: var sup models.TenantCrmSupplier if err := models.Orm.QueryTable(new(models.TenantCrmSupplier)). Filter("id", refID).Filter("tenant_id", tenantID). Filter("delete_time__isnull", true).One(&sup); err != nil { return nil, 0, name } id := sup.ID if strings.TrimSpace(sup.SupplierName) != "" { name = sup.SupplierName } return &id, 2, name case 1: var cus models.TenantCrmCustomer if err := models.Orm.QueryTable(new(models.TenantCrmCustomer)). Filter("id", refID).Filter("tenant_id", tenantID). Filter("delete_time__isnull", true).One(&cus); err != nil { return nil, 0, name } id := cus.ID if strings.TrimSpace(cus.CustomerName) != "" { name = cus.CustomerName } return &id, 1, name } return nil, 0, name } // attachProjectContractPeriod 汇总项目下所有合同的实际起止时间: // 开始取各合同最早的生效日期(未填生效日期时回退签订日期),结束取最晚的结束日期。 // 项目不再手工维护起止时间,列表 / 详情统一按此展示;改合同后自动跟随,无需同步。 func attachProjectContractPeriod(tenantID string, list []models.TenantCrmProject) { if len(list) == 0 { return } idx := make(map[uint64]int, len(list)) ids := make([]uint64, 0, len(list)) for i := range list { idx[list[i].ID] = i ids = append(ids, list[i].ID) } var rows []models.TenantCrmContract _, err := models.Orm.QueryTable(new(models.TenantCrmContract)). Filter("tenant_id", tenantID).Filter("delete_time__isnull", true). Filter("project_id__in", ids). All(&rows, "project_id", "sign_date", "effective_date", "expire_date") if err != nil { return } for i := range rows { ct := rows[i] if ct.ProjectID == nil { continue } pos, ok := idx[*ct.ProjectID] if !ok { continue } start := ct.EffectiveDate if start == nil { start = ct.SignDate } if start != nil && (list[pos].ContractStartDate == nil || start.Before(*list[pos].ContractStartDate)) { list[pos].ContractStartDate = start } if ct.ExpireDate != nil && (list[pos].ContractEndDate == nil || ct.ExpireDate.After(*list[pos].ContractEndDate)) { list[pos].ContractEndDate = ct.ExpireDate } } } // crmProjectNameDuplicated 项目重名校验:同租户 + 同名 + 同一客户(服务主体)才算重复。 // 不同客户的同名项目可正常创建(如两家公司都有「官网建设」项目); // 未关联客户的项目按「客户为空」分组校验(与同样未关联客户的同名项目视为重复)。 func crmProjectNameDuplicated(tenantID, name string, customerID *uint64) bool { name = strings.TrimSpace(name) if name == "" || strings.TrimSpace(tenantID) == "" || models.Orm == nil { return false } cond := orm.NewCondition(). And("tenant_id", tenantID). And("delete_time__isnull", true). And("project_name", name) if customerID != nil && *customerID > 0 { cond = cond.And("customer_id", *customerID) } else { cond = cond.AndCond(orm.NewCondition().Or("customer_id__isnull", true).Or("customer_id", 0)) } cnt, err := models.Orm.QueryTable(new(models.TenantCrmProject)).SetCond(cond).Count() return err == nil && cnt > 0 } // 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) // buildCond 构造本次列表的筛选条件。 // 「已完成」沉底排列需要按「未完成 / 已完成」两组分别查询,因此这里做成可重复调用的构造方法。 buildCond := func() *orm.Condition { cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true) // 数据范围:平台/租户管理员可见全部,其他用户仅本人(负责人或创建人) cond = crmApplyOwnerScope(cond, claims) if keyword != "" { kw := orm.NewCondition(). Or("project_name__contains", keyword). Or("project_no__contains", keyword). Or("customer_name__contains", keyword). Or("supplier_name__contains", keyword) cond = cond.AndCond(kw) } if status != "" { cond = cond.And("status", status) } if ownerID != "" { cond = cond.And("owner_user_id", ownerID) } return cond } projectQuery := func(cond *orm.Condition) orm.QuerySeter { return models.Orm.QueryTable(new(models.TenantCrmProject)).SetCond(cond) } total, _ := projectQuery(buildCond()).Count() var list []models.TenantCrmProject if total > 0 { offset := (page - 1) * pageSize if status != "" { // 已按状态筛选:结果只含单一状态,直接按新→旧分页 if _, err := projectQuery(buildCond()).OrderBy("-id").Offset(offset).Limit(pageSize).All(&list); err != nil { pipelineErr(&c.Controller, 500, 500, "查询失败: "+err.Error()) return } } else { // 列表排列:按状态分组展示 —— 未开始(1) → 异常(5) → 进行中(2) → 已暂停(4) → 已完成(3), // 其余状态(NULL / 历史异常值)沉底;组内仍按新→旧。 // 先统计各组数量,把当前页窗口换算成各组区间,再分别取数按序拼接,保证翻页后整体顺序一致。 groupConds := []*orm.Condition{ orm.NewCondition().And("status", 1), // 未开始 orm.NewCondition().And("status", 5), // 异常 orm.NewCondition().And("status", 2), // 进行中 orm.NewCondition().And("status", 4), // 已暂停 orm.NewCondition().And("status", 3), // 已完成 orm.NewCondition().AndNot("status__in", []int8{1, 2, 3, 4, 5}).Or("status__isnull", true), } end := offset + pageSize if end > int(total) { end = int(total) } cursor := 0 for _, gc := range groupConds { if cursor >= end { break } // 注意:beego 的 AndCond 会修改接收者,这里用新条件对象包装,避免循环内条件累积 groupCond := orm.NewCondition().AndCond(buildCond()).AndCond(gc) cnt, _ := projectQuery(groupCond).Count() groupTotal := int(cnt) if groupTotal == 0 { continue } groupStart := cursor groupEnd := cursor + groupTotal cursor = groupEnd // 当前页与本组区间的交集 from, to := max(offset, groupStart), min(end, groupEnd) if to <= from { continue } var part []models.TenantCrmProject if _, err := projectQuery(groupCond).OrderBy("-id").Offset(from - groupStart).Limit(to - from).All(&part); err != nil { pipelineErr(&c.Controller, 500, 500, "查询失败: "+err.Error()) return } list = append(list, part...) } } } attachBusinessName(tenantID, list) // 项目起止时间由关联合同汇总(不再依赖手工维护的开始 / 结束日期) attachProjectContractPeriod(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) attachProjectContractPeriod(pipelineTenantID(claims), tmp) proj.BusinessName = tmp[0].BusinessName proj.ContractStartDate = tmp[0].ContractStartDate proj.ContractEndDate = tmp[0].ContractEndDate // 存量项目兜底:确保文档库「共享文档 / 项目文档」下已建立同名文件夹(失败静默,不影响详情) 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 } // 项目类型 / 上下游金额 / 上下游对接人归一(单一项目自动清空下游信息) pt, upstream, downstream, upContact, upPhone, downContact, downPhone := normalizeProjectFields(&p) supplierID, supplierRefType, supplierName := resolveProjectSupplier(tenantID, p.SupplierRefType, p.SupplierID, p.SupplierName) if pt == 1 { supplierID, supplierRefType, supplierName = nil, 0, "" } now := time.Now() proj := models.TenantCrmProject{ TenantID: tenantID, ProjectName: strings.TrimSpace(p.ProjectName), ProjectNo: strings.TrimSpace(p.ProjectNo), ProjectType: pt, CustomerID: custIDVal, CustomerName: customerName, SupplierID: supplierID, SupplierRefType: supplierRefType, SupplierName: supplierName, SupplierIndustry: strings.TrimSpace(p.SupplierIndustry), OwnerUserID: firstNonEmpty(p.OwnerUserID, pipelineUID(claims)), OwnerUserName: firstNonEmpty(p.OwnerUserName, resolveUserName(claims)), Industry: strings.TrimSpace(p.Industry), UpstreamAmount: upstream, DownstreamAmount: downstream, Amount: upstream, // 兼容旧字段:始终等于上游金额 Status: status, StartDate: parsePipelineDate(p.StartDate), EndDate: parsePipelineDate(p.EndDate), UpstreamContact: upContact, UpstreamPhone: upPhone, DownstreamContact: downContact, DownstreamPhone: downPhone, ContactPerson: upContact, // 旧字段同步为上游对接人,便于历史查询 ContactPhone: upPhone, Address: strings.TrimSpace(p.Address), Remark: p.Remark, Products: p.Products, CreateUserID: pipelineUID(claims), CreateTime: now, UpdateTime: now, } // 唯一性校验:同一客户(服务主体)下的同名项目不允许重复创建(重复数据请联系管理员转移跟进); // 客户不同则允许同名创建(不同主体可各自立项,如两家客户都做「官网建设」) if crmProjectNameDuplicated(tenantID, proj.ProjectName, proj.CustomerID) { pipelineErr(&c.Controller, 409, 409, "该客户下已存在同名项目(可能由其他同事创建),如需继续跟进请联系管理员转移") return } id, err := models.Orm.Insert(&proj) if err != nil { pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error()) return } // 项目生成时,把产品清单里的产品参数写入产品管理(按 名称+分类 去重) if strings.TrimSpace(p.Products) != "" { var items []crmProductItem if json.Unmarshal([]byte(p.Products), &items) == nil { if _, _, serr := SyncProjectProducts(tenantID, uint64(id), proj.ProjectName, items); serr != nil { // 同步失败不影响项目创建,仅记录 _ = serr } } } // 项目建立后,同步在文档库「共享文档 / 项目文档」下建立同名文件夹(失败不影响项目创建) 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 !canAccessCrmRecord(claims, proj.OwnerUserID, proj.CreateUserID) { pipelineErr(&c.Controller, 403, 403, "无权操作该项目数据") 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) // 项目类型 / 上下游金额 / 上下游对接人(单一项目自动清空下游信息) pt, upstream, downstream, upContact, upPhone, downContact, downPhone := normalizeProjectFields(&p) supplierID, supplierRefType, supplierName := resolveProjectSupplier(tenantID, p.SupplierRefType, p.SupplierID, p.SupplierName) if pt == 1 { supplierID, supplierRefType, supplierName = nil, 0, "" } proj.ProjectType = pt proj.SupplierID = supplierID proj.SupplierRefType = supplierRefType proj.SupplierName = supplierName proj.SupplierIndustry = strings.TrimSpace(p.SupplierIndustry) proj.UpstreamAmount = upstream proj.DownstreamAmount = downstream proj.Amount = upstream // 兼容旧字段:始终等于上游金额 proj.UpstreamContact = upContact proj.UpstreamPhone = upPhone proj.DownstreamContact = downContact proj.DownstreamPhone = downPhone proj.ContactPerson = upContact proj.ContactPhone = upPhone if p.Status != 0 { proj.Status = p.Status } proj.StartDate = parsePipelineDate(p.StartDate) proj.EndDate = parsePipelineDate(p.EndDate) proj.Address = strings.TrimSpace(p.Address) proj.Remark = p.Remark proj.Products = p.Products proj.UpdateTime = time.Now() if _, err := models.Orm.Update(&proj); err != nil { pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error()) return } // 项目保存时,把产品清单里的产品参数写入产品管理(按 名称+分类 去重) if strings.TrimSpace(p.Products) != "" { var items []crmProductItem if json.Unmarshal([]byte(p.Products), &items) == nil { _, _, _ = SyncProjectProducts(tenantID, proj.ID, proj.ProjectName, items) } } // 项目改名时同步重命名文档库中的项目文件夹(失败静默,不影响项目更新) 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 !canAccessCrmRecord(claims, proj.OwnerUserID, proj.CreateUserID) { pipelineErr(&c.Controller, 403, 403, "无权操作该项目数据") 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) } // ========================== 项目联系人(从对应企业通讯录挑选) ========================== // contactIDsJSON 把联系人ID数组序列化为 JSON 字符串;空集合返回空串。 func contactIDsJSON(ids []uint64) string { if len(ids) == 0 { return "" } b, err := json.Marshal(ids) if err != nil { return "" } return string(b) } // filterCompanyContactIDs 过滤出确实属于指定企业通讯录的联系人ID(防止跨企业/脏ID混入)。 // companyID 为空或 ids 为空时直接返回空集合。 func filterCompanyContactIDs(tenantID string, companyType string, companyID *uint64, ids []uint64) []uint64 { if companyID == nil || *companyID == 0 || len(ids) == 0 { return nil } var rows []models.ErpCompanyContact _, _ = models.Orm.QueryTable(new(models.ErpCompanyContact)). Filter("tenant_id", tenantID). Filter("company_type", companyType). Filter("company_id", *companyID). Filter("delete_time__isnull", true). All(&rows) allowed := make(map[uint64]bool, len(rows)) for _, r := range rows { allowed[r.ID] = true } out := make([]uint64, 0, len(ids)) seen := make(map[uint64]bool, len(ids)) for _, id := range ids { if id > 0 && allowed[id] && !seen[id] { out = append(out, id) seen[id] = true } } return out } // SaveContacts POST /backend/crm/project/:id/contacts // 保存项目联系人:从对应企业通讯录中挑选的联系人ID集合(支持多选)。 // - 上游=客户方(customer_id 对应通讯录); // - 下游/中游=对方主体方(supplier_ref_type:1=客户库 2=供应商库,supplier_id 对应通讯录); // - 单一项目(project_type=1)只保留上游集合,下游集合自动清空; // - 传入不属于对应企业的联系人ID会被过滤掉。 func (c *BackendCrmProjectController) SaveContacts() { 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 struct { UpstreamContactIDs []uint64 `json:"upstream_contact_ids"` DownstreamContactIDs []uint64 `json:"downstream_contact_ids"` } 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 !canAccessCrmRecord(claims, proj.OwnerUserID, proj.CreateUserID) { pipelineErr(&c.Controller, 403, 403, "无权操作该项目数据") return } pt := proj.ProjectType if pt != 2 && pt != 3 { pt = 1 } upIDs := filterCompanyContactIDs(tenantID, "customer", proj.CustomerID, p.UpstreamContactIDs) var downIDs []uint64 // 上下游 / 上中游项目:对方主体(下游 / 中游)联系人按 supplier_ref_type 选择对应库 if pt != 1 && proj.SupplierID != nil && *proj.SupplierID > 0 { companyType := "supplier" if proj.SupplierRefType == 1 { companyType = "customer" } downIDs = filterCompanyContactIDs(tenantID, companyType, proj.SupplierID, p.DownstreamContactIDs) } now := time.Now() _, err = models.Orm.QueryTable(new(models.TenantCrmProject)). Filter("id", proj.ID).Filter("tenant_id", tenantID). Update(map[string]interface{}{ "upstream_contact_ids": contactIDsJSON(upIDs), "downstream_contact_ids": contactIDsJSON(downIDs), "update_time": now, }) if err != nil { pipelineErr(&c.Controller, 500, 500, "保存项目联系人失败: "+err.Error()) return } crmWriteLog(tenantID, 3, proj.ID, "update", "更新项目联系人:"+proj.ProjectName, claims) pipelineOk(&c.Controller, map[string]interface{}{ "upstream_contact_ids": upIDs, "downstream_contact_ids": downIDs, }) } // ChangeStatus POST /backend/crm/project/:id/status // 项目状态流转:1=未开始 2=进行中 3=已完成 4=已暂停 5=异常。 // 各状态间可互切;仅更新状态字段,不触碰其他业务字段。 func (c *BackendCrmProjectController) ChangeStatus() { 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 struct { Status int8 `json:"status"` } if err := json.Unmarshal(raw, &p); err != nil { pipelineErr(&c.Controller, 400, 400, "参数错误") return } if p.Status < 1 || p.Status > 5 { 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 !canAccessCrmRecord(claims, proj.OwnerUserID, proj.CreateUserID) { pipelineErr(&c.Controller, 403, 403, "无权操作该项目数据") return } if proj.Status == p.Status { pipelineOk(&c.Controller, map[string]interface{}{"id": proj.ID, "status": proj.Status}) return } if _, err := models.Orm.QueryTable(new(models.TenantCrmProject)). Filter("id", id).Filter("tenant_id", tenantID). Update(map[string]interface{}{"status": p.Status, "update_time": time.Now()}); err != nil { pipelineErr(&c.Controller, 500, 500, "状态更新失败: "+err.Error()) return } crmWriteLog(tenantID, 3, proj.ID, "status", "项目状态流转:"+projectStatusName(proj.Status)+" → "+projectStatusName(p.Status), claims) pipelineOk(&c.Controller, map[string]interface{}{"id": proj.ID, "status": p.Status}) } // ========================== 项目文档(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) }