package controllers import ( "encoding/json" "fmt" "io" "strconv" "strings" "time" "server/models" "github.com/beego/beego/v2/client/orm" beego "github.com/beego/beego/v2/server/web" ) // BackendCrmBusinessController CRM 商机管理 type BackendCrmBusinessController struct { beego.Controller } type businessPayload struct { BusinessName string `json:"business_name"` CustomerID uint64 `json:"customer_id"` CustomerName string `json:"customer_name"` Source string `json:"source"` OwnerUserID string `json:"owner_user_id"` OwnerUserName string `json:"owner_user_name"` Industry string `json:"industry"` Stage string `json:"stage"` Amount float64 `json:"amount"` ExpectDealDate string `json:"expect_deal_date"` NextContactTime string `json:"next_contact_time"` Level string `json:"level"` ContactPerson string `json:"contact_person"` ContactPosition string `json:"contact_position"` ContactPhone string `json:"contact_phone"` ContactWechat string `json:"contact_wechat"` ContactQQ string `json:"contact_qq"` Address string `json:"address"` Remark string `json:"remark"` } // List GET /backend/crm/business/list func (c *BackendCrmBusinessController) 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")) stage := strings.TrimSpace(c.GetString("stage")) level := strings.TrimSpace(c.GetString("level")) 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) // 数据范围:平台/租户管理员可见全部,其他用户仅本人(负责人或创建人) cond = crmApplyOwnerScope(cond, claims) if keyword != "" { kw := orm.NewCondition(). Or("business_name__contains", keyword). Or("customer_name__contains", keyword). Or("contact_person__contains", keyword) cond = cond.AndCond(kw) } if stage != "" { cond = cond.And("stage", stage) } if level != "" { cond = cond.And("level", level) } if status != "" { cond = cond.And("status", status) } if ownerID != "" { cond = cond.And("owner_user_id", ownerID) } qs := models.Orm.QueryTable(new(models.TenantCrmBusiness)).SetCond(cond) total, _ := qs.Count() var list []models.TenantCrmBusiness if total > 0 { // 未转化的排前面,已转化的(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, }) } // Detail GET /backend/crm/business/:id func (c *BackendCrmBusinessController) 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 biz models.TenantCrmBusiness err = models.Orm.QueryTable(new(models.TenantCrmBusiness)). Filter("id", id).Filter("tenant_id", pipelineTenantID(claims)). Filter("delete_time__isnull", true).One(&biz) if err != nil { pipelineErr(&c.Controller, 404, 404, "商机未找到") return } tmp := []models.TenantCrmBusiness{biz} attachClueName(pipelineTenantID(claims), tmp) biz.ClueName = tmp[0].ClueName pipelineOk(&c.Controller, biz) } // Create POST /backend/crm/business func (c *BackendCrmBusinessController) 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 businessPayload if err := json.Unmarshal(raw, &p); err != nil { pipelineErr(&c.Controller, 400, 400, "参数错误") return } if strings.TrimSpace(p.BusinessName) == "" { pipelineErr(&c.Controller, 400, 400, "商机名称不能为空") return } if strings.TrimSpace(p.CustomerName) == "" && p.CustomerID == 0 { pipelineErr(&c.Controller, 400, 400, "请选择或填写客户") return } tenantID := pipelineTenantID(claims) customerName := strings.TrimSpace(p.CustomerName) var custIDVal *uint64 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 } } if customerName == "" { pipelineErr(&c.Controller, 400, 400, "客户名称不能为空") return } ownerID := firstNonEmpty(p.OwnerUserID, pipelineUID(claims)) ownerName := firstNonEmpty(p.OwnerUserName, resolveUserName(claims)) stage := firstNonEmpty(p.Stage, "1") level := firstNonEmpty(p.Level, "2") now := time.Now() biz := models.TenantCrmBusiness{ TenantID: tenantID, BusinessName: strings.TrimSpace(p.BusinessName), CustomerID: custIDVal, CustomerName: customerName, Source: strings.TrimSpace(p.Source), OwnerUserID: ownerID, OwnerUserName: ownerName, Industry: strings.TrimSpace(p.Industry), Stage: stage, Amount: p.Amount, ExpectDealDate: parsePipelineDate(p.ExpectDealDate), NextContactTime: parsePipelineDateTime(p.NextContactTime), Level: level, ContactPerson: strings.TrimSpace(p.ContactPerson), ContactPosition: strings.TrimSpace(p.ContactPosition), ContactPhone: strings.TrimSpace(p.ContactPhone), ContactWechat: strings.TrimSpace(p.ContactWechat), ContactQQ: strings.TrimSpace(p.ContactQQ), Address: strings.TrimSpace(p.Address), Status: 1, Remark: p.Remark, CreateUserID: pipelineUID(claims), CreateTime: now, UpdateTime: now, } // 唯一性校验:同租户内不允许重复创建同名商机(重复数据请联系管理员转移跟进) if crmNameDuplicated(new(models.TenantCrmBusiness), tenantID, "business_name", biz.BusinessName) { pipelineErr(&c.Controller, 409, 409, "该商机名称已存在(可能由其他同事创建),如需继续跟进请联系管理员转移") return } id, err := models.Orm.Insert(&biz) if err != nil { pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error()) return } crmWriteLog(tenantID, 2, uint64(id), "create", "创建商机:"+biz.BusinessName, claims) pipelineOk(&c.Controller, map[string]interface{}{"id": id}) } // Update PUT /backend/crm/business/:id func (c *BackendCrmBusinessController) 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 businessPayload if err := json.Unmarshal(raw, &p); err != nil { pipelineErr(&c.Controller, 400, 400, "参数错误") return } tenantID := pipelineTenantID(claims) var biz models.TenantCrmBusiness if err := models.Orm.QueryTable(new(models.TenantCrmBusiness)). Filter("id", id).Filter("tenant_id", tenantID). Filter("delete_time__isnull", true).One(&biz); err != nil { pipelineErr(&c.Controller, 404, 404, "商机未找到") return } // 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据 if !canAccessCrmRecord(claims, biz.OwnerUserID, biz.CreateUserID) { pipelineErr(&c.Controller, 403, 403, "无权操作该商机数据") return } if biz.Locked == 1 { pipelineErr(&c.Controller, 400, 400, "商机已转为项目并锁定,不能编辑") return } if strings.TrimSpace(p.BusinessName) == "" { pipelineErr(&c.Controller, 400, 400, "商机名称不能为空") return } biz.BusinessName = strings.TrimSpace(p.BusinessName) 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 biz.CustomerID = &cid biz.CustomerName = cust.CustomerName } } else if strings.TrimSpace(p.CustomerName) != "" { biz.CustomerName = strings.TrimSpace(p.CustomerName) } biz.Source = strings.TrimSpace(p.Source) if strings.TrimSpace(p.OwnerUserID) != "" { biz.OwnerUserID = strings.TrimSpace(p.OwnerUserID) } if strings.TrimSpace(p.OwnerUserName) != "" { biz.OwnerUserName = strings.TrimSpace(p.OwnerUserName) } biz.Industry = strings.TrimSpace(p.Industry) if strings.TrimSpace(p.Stage) != "" { biz.Stage = strings.TrimSpace(p.Stage) } biz.Amount = p.Amount biz.ExpectDealDate = parsePipelineDate(p.ExpectDealDate) biz.NextContactTime = parsePipelineDateTime(p.NextContactTime) if strings.TrimSpace(p.Level) != "" { biz.Level = strings.TrimSpace(p.Level) } biz.ContactPerson = strings.TrimSpace(p.ContactPerson) biz.ContactPosition = strings.TrimSpace(p.ContactPosition) biz.ContactPhone = strings.TrimSpace(p.ContactPhone) biz.ContactWechat = strings.TrimSpace(p.ContactWechat) biz.ContactQQ = strings.TrimSpace(p.ContactQQ) biz.Address = strings.TrimSpace(p.Address) biz.Remark = p.Remark biz.UpdateTime = time.Now() if _, err := models.Orm.Update(&biz); err != nil { pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error()) return } crmWriteLog(tenantID, 2, biz.ID, "update", "更新商机:"+biz.BusinessName, claims) pipelineOk(&c.Controller, map[string]interface{}{"id": biz.ID}) } // Delete DELETE /backend/crm/business/:id func (c *BackendCrmBusinessController) 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 biz models.TenantCrmBusiness if err := models.Orm.QueryTable(new(models.TenantCrmBusiness)). Filter("id", id).Filter("tenant_id", tenantID). Filter("delete_time__isnull", true).One(&biz); err != nil { pipelineErr(&c.Controller, 404, 404, "商机未找到") return } // 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据 if !canAccessCrmRecord(claims, biz.OwnerUserID, biz.CreateUserID) { pipelineErr(&c.Controller, 403, 403, "无权操作该商机数据") 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 } now := time.Now() _, err = models.Orm.QueryTable(new(models.TenantCrmBusiness)). 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) } // Convert POST /backend/crm/business/:id/convert 商机转项目 func (c *BackendCrmBusinessController) Convert() { 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 p struct { ProjectName string `json:"project_name"` ProjectNo string `json:"project_no"` Amount float64 `json:"amount"` StartDate string `json:"start_date"` EndDate string `json:"end_date"` Remark string `json:"remark"` } raw, _ := io.ReadAll(c.Ctx.Request.Body) if err := json.Unmarshal(raw, &p); err != nil { pipelineErr(&c.Controller, 400, 400, "参数错误") return } tenantID := pipelineTenantID(claims) var biz models.TenantCrmBusiness if err := models.Orm.QueryTable(new(models.TenantCrmBusiness)). Filter("id", id).Filter("tenant_id", tenantID). Filter("delete_time__isnull", true).One(&biz); err != nil { pipelineErr(&c.Controller, 404, 404, "商机未找到") return } // 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据 if !canAccessCrmRecord(claims, biz.OwnerUserID, biz.CreateUserID) { pipelineErr(&c.Controller, 403, 403, "无权操作该商机数据") return } if biz.Locked == 1 || biz.Status == 2 { pipelineErr(&c.Controller, 400, 400, "商机已转化,不能重复转化") return } projectName := strings.TrimSpace(p.ProjectName) if projectName == "" { projectName = biz.BusinessName } amount := p.Amount if amount == 0 { amount = biz.Amount } now := time.Now() bizIDVal := biz.ID proj := models.TenantCrmProject{ TenantID: tenantID, ProjectName: projectName, ProjectNo: strings.TrimSpace(p.ProjectNo), // 商机转化默认按「单一项目」创建:金额与对接人先落到上游侧, // 后续可在项目中选择「上下游项目」并补充下游供应商 / 金额 / 对接人 ProjectType: 1, BusinessID: &bizIDVal, CustomerID: biz.CustomerID, CustomerName: biz.CustomerName, OwnerUserID: biz.OwnerUserID, OwnerUserName: biz.OwnerUserName, Industry: biz.Industry, UpstreamAmount: amount, Amount: amount, // 兼容旧字段 Status: 1, StartDate: parsePipelineDate(p.StartDate), EndDate: parsePipelineDate(p.EndDate), UpstreamContact: biz.ContactPerson, UpstreamPhone: biz.ContactPhone, ContactPerson: biz.ContactPerson, ContactPosition: biz.ContactPosition, ContactPhone: biz.ContactPhone, Address: biz.Address, Remark: p.Remark, CreateUserID: pipelineUID(claims), CreateTime: now, UpdateTime: now, } projID, err := models.Orm.Insert(&proj) if err != nil { pipelineErr(&c.Controller, 500, 500, "创建项目失败: "+err.Error()) return } // 锁定商机 biz.Status = 2 biz.Locked = 1 pid := uint64(projID) biz.ProjectID = &pid biz.ConvertTime = &now biz.UpdateTime = now _, _ = models.Orm.Update(&biz, "status", "locked", "project_id", "convert_time", "update_time") crmWriteLog(tenantID, 2, biz.ID, "convert", "商机转化为项目:"+projectName, claims) crmWriteLog(tenantID, 3, uint64(projID), "create", "由商机「"+biz.BusinessName+"」转化生成", claims) // 生成项目时,把「商机 + 来源线索」过程库中的联系人去重同步到正式公司联系人库 if biz.CustomerID != nil && *biz.CustomerID > 0 { var contacts []models.TenantCrmEntityContact var bizContacts []models.TenantCrmEntityContact _, _ = models.Orm.QueryTable(new(models.TenantCrmEntityContact)). Filter("tenant_id", tenantID). Filter("related_type", int8(2)). Filter("related_id", biz.ID). Filter("delete_time__isnull", true). All(&bizContacts) contacts = append(contacts, bizContacts...) if biz.ClueID != nil && *biz.ClueID > 0 { var clueContacts []models.TenantCrmEntityContact _, _ = models.Orm.QueryTable(new(models.TenantCrmEntityContact)). Filter("tenant_id", tenantID). Filter("related_type", int8(1)). Filter("related_id", *biz.ClueID). Filter("delete_time__isnull", true). All(&clueContacts) contacts = append(contacts, clueContacts...) } insertedN, mergedN := syncContactsToCompany(tenantID, *biz.CustomerID, contacts) if insertedN+mergedN > 0 { crmWriteLog(tenantID, 3, uint64(projID), "sync-contacts", fmt.Sprintf("同步联系人到正式库:新增 %d,合并 %d", insertedN, mergedN), claims) } } pipelineOk(&c.Controller, map[string]interface{}{"project_id": projID}) }