1108 lines
40 KiB
Go
1108 lines
40 KiB
Go
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)
|
||
}
|