优化项目管理
This commit is contained in:
@@ -22,23 +22,141 @@ type BackendCrmProjectController struct {
|
||||
}
|
||||
|
||||
type projectPayload struct {
|
||||
ProjectName string `json:"project_name"`
|
||||
ProjectNo string `json:"project_no"`
|
||||
CustomerID uint64 `json:"customer_id"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
OwnerUserID string `json:"owner_user_id"`
|
||||
OwnerUserName string `json:"owner_user_name"`
|
||||
Industry string `json:"industry"`
|
||||
Amount float64 `json:"amount"`
|
||||
Status int8 `json:"status"`
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
ContactPerson string `json:"contact_person"`
|
||||
ContactPosition string `json:"contact_position"`
|
||||
ContactPhone string `json:"contact_phone"`
|
||||
Address string `json:"address"`
|
||||
Remark string `json:"remark"`
|
||||
Products string `json:"products"` // 项目产品清单JSON(生成时写入产品管理)
|
||||
ProjectName string `json:"project_name"`
|
||||
ProjectNo string `json:"project_no"`
|
||||
// 项目类型:1=单一项目(仅上游客户) 2=上下游项目(上游客户 + 下游供应商)
|
||||
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=上下游,其余一律按 1=单一项目处理;
|
||||
// - 上游金额 / 上游对接人缺省时回退旧的 amount / contact_* 字段(兼容未升级的调用方);
|
||||
// - 单一项目不存在下游,下游金额与下游对接人一并清空。
|
||||
func normalizeProjectFields(p *projectPayload) (pt int8, upstream, downstream float64, upContact, upPhone, downContact, downPhone string) {
|
||||
pt = p.ProjectType
|
||||
if pt != 2 {
|
||||
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))
|
||||
if pt == 2 {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// List GET /backend/crm/project/list
|
||||
@@ -62,11 +180,14 @@ func (c *BackendCrmProjectController) List() {
|
||||
|
||||
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("project_name__contains", keyword).
|
||||
Or("project_no__contains", keyword).
|
||||
Or("customer_name__contains", keyword)
|
||||
Or("customer_name__contains", keyword).
|
||||
Or("supplier_name__contains", keyword)
|
||||
cond = cond.AndCond(kw)
|
||||
}
|
||||
if status != "" {
|
||||
@@ -83,6 +204,8 @@ func (c *BackendCrmProjectController) List() {
|
||||
_, _ = qs.OrderBy("-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
|
||||
}
|
||||
attachBusinessName(tenantID, list)
|
||||
// 项目起止时间由关联合同汇总(不再依赖手工维护的开始 / 结束日期)
|
||||
attachProjectContractPeriod(tenantID, list)
|
||||
pipelineOk(&c.Controller, map[string]interface{}{
|
||||
"list": list, "total": total, "page": page, "pageSize": pageSize,
|
||||
})
|
||||
@@ -110,7 +233,10 @@ func (c *BackendCrmProjectController) Detail() {
|
||||
}
|
||||
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 {
|
||||
@@ -155,29 +281,50 @@ func (c *BackendCrmProjectController) Create() {
|
||||
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),
|
||||
CustomerID: custIDVal,
|
||||
CustomerName: customerName,
|
||||
OwnerUserID: firstNonEmpty(p.OwnerUserID, pipelineUID(claims)),
|
||||
OwnerUserName: firstNonEmpty(p.OwnerUserName, resolveUserName(claims)),
|
||||
Industry: strings.TrimSpace(p.Industry),
|
||||
Amount: p.Amount,
|
||||
Status: status,
|
||||
StartDate: parsePipelineDate(p.StartDate),
|
||||
EndDate: parsePipelineDate(p.EndDate),
|
||||
ContactPerson: strings.TrimSpace(p.ContactPerson),
|
||||
ContactPosition: strings.TrimSpace(p.ContactPosition),
|
||||
ContactPhone: strings.TrimSpace(p.ContactPhone),
|
||||
Address: strings.TrimSpace(p.Address),
|
||||
Remark: p.Remark,
|
||||
Products: p.Products,
|
||||
CreateUserID: pipelineUID(claims),
|
||||
CreateTime: now,
|
||||
UpdateTime: now,
|
||||
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 crmNameDuplicated(new(models.TenantCrmProject), tenantID, "project_name", proj.ProjectName) {
|
||||
pipelineErr(&c.Controller, 409, 409, "该项目名称已存在(可能由其他同事创建),如需继续跟进请联系管理员转移")
|
||||
return
|
||||
}
|
||||
id, err := models.Orm.Insert(&proj)
|
||||
if err != nil {
|
||||
@@ -228,6 +375,11 @@ func (c *BackendCrmProjectController) Update() {
|
||||
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
|
||||
@@ -254,15 +406,31 @@ func (c *BackendCrmProjectController) Update() {
|
||||
proj.OwnerUserName = strings.TrimSpace(p.OwnerUserName)
|
||||
}
|
||||
proj.Industry = strings.TrimSpace(p.Industry)
|
||||
proj.Amount = p.Amount
|
||||
// 项目类型 / 上下游金额 / 上下游对接人(单一项目自动清空下游信息)
|
||||
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.ContactPerson = strings.TrimSpace(p.ContactPerson)
|
||||
proj.ContactPosition = strings.TrimSpace(p.ContactPosition)
|
||||
proj.ContactPhone = strings.TrimSpace(p.ContactPhone)
|
||||
proj.Address = strings.TrimSpace(p.Address)
|
||||
proj.Remark = p.Remark
|
||||
proj.Products = p.Products
|
||||
@@ -307,6 +475,11 @@ func (c *BackendCrmProjectController) Delete() {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user