优化项目管理

This commit is contained in:
2026-09-13 22:31:42 +08:00
parent 2f6cdbb3cd
commit a8c09c1db5
30 changed files with 1654 additions and 346 deletions
+216 -43
View File
@@ -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