Files
yunzerwebsiteallinone/go/controllers/backend_crm_project.go
T
2026-09-14 17:30:12 +08:00

1108 lines
40 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}