优化项目管理
This commit is contained in:
@@ -64,6 +64,8 @@ func (c *BackendCrmBusinessController) 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("business_name__contains", keyword).
|
||||
@@ -195,6 +197,11 @@ func (c *BackendCrmBusinessController) Create() {
|
||||
CreateTime: now,
|
||||
UpdateTime: now,
|
||||
}
|
||||
// 唯一性校验:同租户内不允许重复创建同名商机(重复数据请联系管理员转移跟进)
|
||||
if crmNameDuplicated(new(models.TenantCrmBusiness), tenantID, "business_name", biz.BusinessName) {
|
||||
pipelineErr(&c.Controller, 409, 409, "该商机名称已存在(可能由其他同事创建),如需继续跟进请联系管理员转移")
|
||||
return
|
||||
}
|
||||
id, err := models.Orm.Insert(&biz)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
|
||||
@@ -230,6 +237,11 @@ func (c *BackendCrmBusinessController) Update() {
|
||||
pipelineErr(&c.Controller, 404, 404, "商机未找到")
|
||||
return
|
||||
}
|
||||
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
|
||||
if !canAccessCrmRecord(claims, biz.OwnerUserID, biz.CreateUserID) {
|
||||
pipelineErr(&c.Controller, 403, 403, "无权操作该商机数据")
|
||||
return
|
||||
}
|
||||
if biz.Locked == 1 {
|
||||
pipelineErr(&c.Controller, 400, 400, "商机已转为项目并锁定,不能编辑")
|
||||
return
|
||||
@@ -306,6 +318,11 @@ func (c *BackendCrmBusinessController) Delete() {
|
||||
pipelineErr(&c.Controller, 404, 404, "商机未找到")
|
||||
return
|
||||
}
|
||||
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
|
||||
if !canAccessCrmRecord(claims, biz.OwnerUserID, biz.CreateUserID) {
|
||||
pipelineErr(&c.Controller, 403, 403, "无权操作该商机数据")
|
||||
return
|
||||
}
|
||||
if biz.Locked == 1 || biz.Status == 2 {
|
||||
pipelineErr(&c.Controller, 400, 400, "商机已转化并锁定,不能删除")
|
||||
return
|
||||
@@ -359,6 +376,11 @@ func (c *BackendCrmBusinessController) Convert() {
|
||||
pipelineErr(&c.Controller, 404, 404, "商机未找到")
|
||||
return
|
||||
}
|
||||
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
|
||||
if !canAccessCrmRecord(claims, biz.OwnerUserID, biz.CreateUserID) {
|
||||
pipelineErr(&c.Controller, 403, 403, "无权操作该商机数据")
|
||||
return
|
||||
}
|
||||
if biz.Locked == 1 || biz.Status == 2 {
|
||||
pipelineErr(&c.Controller, 400, 400, "商机已转化,不能重复转化")
|
||||
return
|
||||
@@ -375,27 +397,33 @@ func (c *BackendCrmBusinessController) Convert() {
|
||||
now := time.Now()
|
||||
bizIDVal := biz.ID
|
||||
proj := models.TenantCrmProject{
|
||||
TenantID: tenantID,
|
||||
ProjectName: projectName,
|
||||
ProjectNo: strings.TrimSpace(p.ProjectNo),
|
||||
BusinessID: &bizIDVal,
|
||||
CustomerID: biz.CustomerID,
|
||||
CustomerName: biz.CustomerName,
|
||||
OwnerUserID: biz.OwnerUserID,
|
||||
OwnerUserName: biz.OwnerUserName,
|
||||
Industry: biz.Industry,
|
||||
Amount: amount,
|
||||
Status: 1,
|
||||
StartDate: parsePipelineDate(p.StartDate),
|
||||
EndDate: parsePipelineDate(p.EndDate),
|
||||
ContactPerson: biz.ContactPerson,
|
||||
TenantID: tenantID,
|
||||
ProjectName: projectName,
|
||||
ProjectNo: strings.TrimSpace(p.ProjectNo),
|
||||
// 商机转化默认按「单一项目」创建:金额与对接人先落到上游侧,
|
||||
// 后续可在项目中选择「上下游项目」并补充下游供应商 / 金额 / 对接人
|
||||
ProjectType: 1,
|
||||
BusinessID: &bizIDVal,
|
||||
CustomerID: biz.CustomerID,
|
||||
CustomerName: biz.CustomerName,
|
||||
OwnerUserID: biz.OwnerUserID,
|
||||
OwnerUserName: biz.OwnerUserName,
|
||||
Industry: biz.Industry,
|
||||
UpstreamAmount: amount,
|
||||
Amount: amount, // 兼容旧字段
|
||||
Status: 1,
|
||||
StartDate: parsePipelineDate(p.StartDate),
|
||||
EndDate: parsePipelineDate(p.EndDate),
|
||||
UpstreamContact: biz.ContactPerson,
|
||||
UpstreamPhone: biz.ContactPhone,
|
||||
ContactPerson: biz.ContactPerson,
|
||||
ContactPosition: biz.ContactPosition,
|
||||
ContactPhone: biz.ContactPhone,
|
||||
Address: biz.Address,
|
||||
Remark: p.Remark,
|
||||
CreateUserID: pipelineUID(claims),
|
||||
CreateTime: now,
|
||||
UpdateTime: now,
|
||||
ContactPhone: biz.ContactPhone,
|
||||
Address: biz.Address,
|
||||
Remark: p.Remark,
|
||||
CreateUserID: pipelineUID(claims),
|
||||
CreateTime: now,
|
||||
UpdateTime: now,
|
||||
}
|
||||
projID, err := models.Orm.Insert(&proj)
|
||||
if err != nil {
|
||||
|
||||
@@ -25,7 +25,8 @@ type cluePayload struct {
|
||||
Source string `json:"source"`
|
||||
OwnerUserID string `json:"owner_user_id"`
|
||||
OwnerUserName string `json:"owner_user_name"`
|
||||
Industry string `json:"industry"`
|
||||
Industry string `json:"industry"`
|
||||
Amount float64 `json:"amount"` // 预计金额
|
||||
NextContactTime string `json:"next_contact_time"`
|
||||
ClueLevel string `json:"clue_level"`
|
||||
ContactPerson string `json:"contact_person"`
|
||||
@@ -61,6 +62,8 @@ func (c *BackendCrmClueController) 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("clue_name__contains", keyword).
|
||||
@@ -114,6 +117,11 @@ func (c *BackendCrmClueController) Detail() {
|
||||
pipelineErr(&c.Controller, 404, 404, "线索未找到")
|
||||
return
|
||||
}
|
||||
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
|
||||
if !canAccessCrmRecord(claims, clue.OwnerUserID, clue.CreateUserID) {
|
||||
pipelineErr(&c.Controller, 403, 403, "无权操作该线索数据")
|
||||
return
|
||||
}
|
||||
pipelineOk(&c.Controller, clue)
|
||||
}
|
||||
|
||||
@@ -161,6 +169,7 @@ func (c *BackendCrmClueController) Create() {
|
||||
OwnerUserID: ownerID,
|
||||
OwnerUserName: ownerName,
|
||||
Industry: strings.TrimSpace(p.Industry),
|
||||
Amount: p.Amount,
|
||||
NextContactTime: parsePipelineDateTime(p.NextContactTime),
|
||||
ClueLevel: level,
|
||||
ContactPerson: strings.TrimSpace(p.ContactPerson),
|
||||
@@ -176,6 +185,11 @@ func (c *BackendCrmClueController) Create() {
|
||||
CreateTime: now,
|
||||
UpdateTime: now,
|
||||
}
|
||||
// 唯一性校验:同租户内不允许重复创建同名线索(重复数据请联系管理员转移跟进)
|
||||
if crmNameDuplicated(new(models.TenantCrmClue), clue.TenantID, "clue_name", clue.ClueName) {
|
||||
pipelineErr(&c.Controller, 409, 409, "该线索名称已存在(可能由其他同事创建),如需继续跟进请联系管理员转移")
|
||||
return
|
||||
}
|
||||
id, err := models.Orm.Insert(&clue)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
|
||||
@@ -212,6 +226,11 @@ func (c *BackendCrmClueController) Update() {
|
||||
pipelineErr(&c.Controller, 404, 404, "线索未找到")
|
||||
return
|
||||
}
|
||||
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
|
||||
if !canAccessCrmRecord(claims, clue.OwnerUserID, clue.CreateUserID) {
|
||||
pipelineErr(&c.Controller, 403, 403, "无权操作该线索数据")
|
||||
return
|
||||
}
|
||||
if clue.Locked == 1 {
|
||||
pipelineErr(&c.Controller, 400, 400, "线索已转化并锁定,不能编辑")
|
||||
return
|
||||
@@ -231,6 +250,7 @@ func (c *BackendCrmClueController) Update() {
|
||||
clue.OwnerUserName = strings.TrimSpace(p.OwnerUserName)
|
||||
}
|
||||
clue.Industry = strings.TrimSpace(p.Industry)
|
||||
clue.Amount = p.Amount
|
||||
clue.NextContactTime = parsePipelineDateTime(p.NextContactTime)
|
||||
if strings.TrimSpace(p.ClueLevel) != "" {
|
||||
clue.ClueLevel = strings.TrimSpace(p.ClueLevel)
|
||||
@@ -272,6 +292,11 @@ func (c *BackendCrmClueController) Delete() {
|
||||
pipelineErr(&c.Controller, 404, 404, "线索未找到")
|
||||
return
|
||||
}
|
||||
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
|
||||
if !canAccessCrmRecord(claims, clue.OwnerUserID, clue.CreateUserID) {
|
||||
pipelineErr(&c.Controller, 403, 403, "无权操作该线索数据")
|
||||
return
|
||||
}
|
||||
if clue.Locked == 1 || clue.Status == 2 {
|
||||
pipelineErr(&c.Controller, 400, 400, "线索已转化并锁定,不能删除")
|
||||
return
|
||||
@@ -340,6 +365,11 @@ func (c *BackendCrmClueController) Convert() {
|
||||
pipelineErr(&c.Controller, 404, 404, "线索未找到")
|
||||
return
|
||||
}
|
||||
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
|
||||
if !canAccessCrmRecord(claims, clue.OwnerUserID, clue.CreateUserID) {
|
||||
pipelineErr(&c.Controller, 403, 403, "无权操作该线索数据")
|
||||
return
|
||||
}
|
||||
if clue.Locked == 1 || clue.Status == 2 {
|
||||
pipelineErr(&c.Controller, 400, 400, "线索已转化,不能重复转化")
|
||||
return
|
||||
|
||||
@@ -47,7 +47,6 @@ type contractPayload struct {
|
||||
SignDate string `json:"sign_date"`
|
||||
EffectiveDate string `json:"effective_date"`
|
||||
ExpireDate string `json:"expire_date"`
|
||||
TechDate string `json:"tech_date"`
|
||||
Parties json.RawMessage `json:"parties"`
|
||||
Products json.RawMessage `json:"products"`
|
||||
Step int8 `json:"step"`
|
||||
@@ -87,6 +86,8 @@ func (c *BackendCrmContractController) List() {
|
||||
|
||||
tenantID := pipelineTenantID(claims)
|
||||
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
|
||||
// 数据范围:平台/租户管理员可见全部,其他用户仅本人(负责人或创建人)
|
||||
cond = crmApplyOwnerScope(cond, claims)
|
||||
if keyword != "" {
|
||||
// 签约主体名称存储在 parties JSON 中,用 LIKE 一并匹配
|
||||
kw := orm.NewCondition().
|
||||
@@ -118,6 +119,20 @@ func (c *BackendCrmContractController) List() {
|
||||
cond = cond.And("project_id", pid)
|
||||
}
|
||||
}
|
||||
// 周期筛选:与顶部统计同口径(合同有效期 [生效日期, 结束日期] 与所选周期重叠),
|
||||
// period=month/quarter/year 取当期区间,period=custom 用 start_date / end_date
|
||||
period := strings.TrimSpace(c.GetString("period"))
|
||||
rangeStart := strings.TrimSpace(c.GetString("start_date"))
|
||||
rangeEnd := strings.TrimSpace(c.GetString("end_date"))
|
||||
if period != "" && period != "custom" {
|
||||
rangeStart, rangeEnd = periodRange(period)
|
||||
}
|
||||
if rangeStart != "" {
|
||||
cond = cond.AndCond(orm.NewCondition().Or("expire_date__isnull", true).Or("expire_date__gte", rangeStart))
|
||||
}
|
||||
if rangeEnd != "" {
|
||||
cond = cond.AndCond(orm.NewCondition().Or("effective_date__isnull", true).Or("effective_date__lte", rangeEnd))
|
||||
}
|
||||
qs := models.Orm.QueryTable(new(models.TenantCrmContract)).SetCond(cond)
|
||||
|
||||
total, _ := qs.Count()
|
||||
@@ -127,7 +142,6 @@ func (c *BackendCrmContractController) List() {
|
||||
}
|
||||
items := make([]contractResp, 0, len(list))
|
||||
for i := range list {
|
||||
c.syncContractExceptionStatus(&list[i])
|
||||
items = append(items, buildContractResp(&list[i]))
|
||||
}
|
||||
pipelineOk(&c.Controller, map[string]interface{}{
|
||||
@@ -185,7 +199,8 @@ func (c *BackendCrmContractController) Stats() {
|
||||
})
|
||||
}
|
||||
|
||||
// periodRange 根据 period 返回 [start, end] 签约日期区间(YYYY-MM-DD):month/quarter/year 取当期。
|
||||
// periodRange 根据 period 返回当期区间 [start, end](YYYY-MM-DD):month/quarter/year。
|
||||
// 统计与列表均以该区间与合同有效期比较(有效期与周期重叠即命中)。
|
||||
func periodRange(period string) (string, string) {
|
||||
now := time.Now()
|
||||
loc := now.Location()
|
||||
@@ -226,7 +241,11 @@ func (c *BackendCrmContractController) Detail() {
|
||||
pipelineErr(&c.Controller, 404, 404, "合同未找到")
|
||||
return
|
||||
}
|
||||
c.syncContractExceptionStatus(&row)
|
||||
// 数据范围校验:非管理员仅能查看本人(负责人或创建人)的数据
|
||||
if !canAccessCrmRecord(claims, row.OwnerUserID, row.CreateUserID) {
|
||||
pipelineErr(&c.Controller, 403, 403, "无权查看该合同数据")
|
||||
return
|
||||
}
|
||||
pipelineOk(&c.Controller, buildContractResp(&row))
|
||||
}
|
||||
|
||||
@@ -262,7 +281,6 @@ func (c *BackendCrmContractController) Create() {
|
||||
SignDate: parsePipelineDate(p.SignDate),
|
||||
EffectiveDate: parsePipelineDate(p.EffectiveDate),
|
||||
ExpireDate: parsePipelineDate(p.ExpireDate),
|
||||
TechDate: parsePipelineDate(p.TechDate),
|
||||
Status: pickInt8(p.Status, 1, 5),
|
||||
Step: pickInt8(p.Step, 1, 2),
|
||||
Remark: p.Remark,
|
||||
@@ -310,6 +328,12 @@ func (c *BackendCrmContractController) Create() {
|
||||
}
|
||||
applyContractAmounts(&row)
|
||||
|
||||
// 唯一性校验:同租户内「合同名称 + 签约方」完全一致才视为重复。
|
||||
// 同名但签约方不同(例如我方分别作为甲方 / 乙方的两份合同)不算重复。
|
||||
if crmContractDuplicated(tenantID, row.ContractName, row.Parties) {
|
||||
pipelineErr(&c.Controller, 409, 409, "已存在同名且签约方相同的合同(可能由其他同事创建),如需继续跟进请联系管理员转移")
|
||||
return
|
||||
}
|
||||
if _, err := models.Orm.Insert(&row); err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
|
||||
return
|
||||
@@ -345,6 +369,11 @@ func (c *BackendCrmContractController) Update() {
|
||||
pipelineErr(&c.Controller, 404, 404, "合同未找到")
|
||||
return
|
||||
}
|
||||
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
|
||||
if !canAccessCrmRecord(claims, row.OwnerUserID, row.CreateUserID) {
|
||||
pipelineErr(&c.Controller, 403, 403, "无权操作该合同数据")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(p.ContractName) == "" {
|
||||
pipelineErr(&c.Controller, 400, 400, "合同名称不能为空")
|
||||
return
|
||||
@@ -385,7 +414,6 @@ func (c *BackendCrmContractController) Update() {
|
||||
row.SignDate = parsePipelineDate(p.SignDate)
|
||||
row.EffectiveDate = parsePipelineDate(p.EffectiveDate)
|
||||
row.ExpireDate = parsePipelineDate(p.ExpireDate)
|
||||
row.TechDate = parsePipelineDate(p.TechDate)
|
||||
if p.Parties != nil {
|
||||
parties, err := normalizeContractJSON(p.Parties)
|
||||
if err != nil {
|
||||
@@ -447,6 +475,11 @@ func (c *BackendCrmContractController) Delete() {
|
||||
pipelineErr(&c.Controller, 404, 404, "合同未找到")
|
||||
return
|
||||
}
|
||||
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
|
||||
if !canAccessCrmRecord(claims, row.OwnerUserID, row.CreateUserID) {
|
||||
pipelineErr(&c.Controller, 403, 403, "无权操作该合同数据")
|
||||
return
|
||||
}
|
||||
if !canDeleteCrmRecord(claims, row.CreateUserID) {
|
||||
pipelineErr(&c.Controller, 403, 403, "只有创建人、租户管理员或平台管理员可以删除该合同")
|
||||
return
|
||||
@@ -497,6 +530,11 @@ func (c *BackendCrmContractController) ChangeStatus() {
|
||||
pipelineErr(&c.Controller, 404, 404, "合同未找到")
|
||||
return
|
||||
}
|
||||
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
|
||||
if !canAccessCrmRecord(claims, row.OwnerUserID, row.CreateUserID) {
|
||||
pipelineErr(&c.Controller, 403, 403, "无权操作该合同数据")
|
||||
return
|
||||
}
|
||||
if row.Status == p.Status {
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID, "status": row.Status})
|
||||
return
|
||||
@@ -684,25 +722,74 @@ func contractReceivedAmount(tenantID string, contractID uint64) float64 {
|
||||
return toFloat64(rows[0]["received"])
|
||||
}
|
||||
|
||||
// syncContractExceptionStatus 履约中合同状态对账:技术日期已过但回款未回完(已收 < 合同总额,
|
||||
// 允许 0.005 浮点容差)时,自动流转为「执行异常」(5)。无技术日期或不满足翻转条件则不动。
|
||||
// 在列表 / 详情读取时触发,也可在回款登记后调用,使状态及时反映。
|
||||
func (c *BackendCrmContractController) syncContractExceptionStatus(row *models.TenantCrmContract) {
|
||||
if row == nil || row.Status != 4 || row.TechDate == nil || row.ID == 0 {
|
||||
return
|
||||
// crmContractDuplicated 判断同租户内是否已存在「合同名称 + 签约方」均一致的合同。
|
||||
//
|
||||
// 判重口径:合同名称一致,且参与方(甲/乙/丙/丁)归一化签名一致。
|
||||
// 因此同一名称下「我方作为甲方」与「我方作为乙方」的两份合同不算重复。
|
||||
func crmContractDuplicated(tenantID, name, partiesJSON string) bool {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || strings.TrimSpace(tenantID) == "" || models.Orm == nil {
|
||||
return false
|
||||
}
|
||||
now := time.Now()
|
||||
if !row.TechDate.Before(now) {
|
||||
return
|
||||
sig := contractPartiesSignature(partiesJSON)
|
||||
var rows []models.TenantCrmContract
|
||||
_, err := models.Orm.QueryTable(new(models.TenantCrmContract)).
|
||||
Filter("tenant_id", tenantID).
|
||||
Filter("delete_time__isnull", true).
|
||||
Filter("contract_name", name).
|
||||
All(&rows, "id", "parties")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
received := contractReceivedAmount(row.TenantID, row.ID)
|
||||
if row.TotalAmount > 0 && received+0.005 < row.TotalAmount {
|
||||
row.Status = 5
|
||||
row.UpdateTime = now
|
||||
_, _ = models.Orm.QueryTable(new(models.TenantCrmContract)).
|
||||
Filter("id", row.ID).Filter("tenant_id", row.TenantID).
|
||||
Update(orm.Params{"status": int8(5), "update_time": now})
|
||||
for i := range rows {
|
||||
if contractPartiesSignature(rows[i].Parties) == sig {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// contractPartiesSignature 参与方归一化签名:按甲/乙/丙/丁固定顺序拼接「角色:主体类型|主体」。
|
||||
// 主体优先取关联主体 ID(ref_id),无 ID(手工录入名称)时退化为名称(ref_name);
|
||||
// 参与方为空或解析失败时返回空串,此时判重退化为「仅合同名称一致」。
|
||||
func contractPartiesSignature(partiesJSON string) string {
|
||||
if strings.TrimSpace(partiesJSON) == "" {
|
||||
return ""
|
||||
}
|
||||
var parties []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(partiesJSON), &parties); err != nil {
|
||||
return ""
|
||||
}
|
||||
str := func(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprintf("%v", v))
|
||||
}
|
||||
byRole := make(map[string]string, len(parties))
|
||||
for _, p := range parties {
|
||||
role := str(p["role"])
|
||||
if role == "" {
|
||||
continue
|
||||
}
|
||||
subject := str(p["ref_id"])
|
||||
// ref_id 为 0 / 空表示未关联主体库(本公司或手工录入),此时用名称兜底,
|
||||
// 避免不同主体都归到 "0" 造成误判为重复
|
||||
if subject == "" || subject == "0" {
|
||||
subject = str(p["ref_name"])
|
||||
}
|
||||
byRole[role] = str(p["ref_type"]) + "|" + subject
|
||||
}
|
||||
var sb strings.Builder
|
||||
for _, role := range []string{"party_a", "party_b", "party_c", "party_d"} {
|
||||
if v, ok := byRole[role]; ok {
|
||||
sb.WriteString(role)
|
||||
sb.WriteString(":")
|
||||
sb.WriteString(v)
|
||||
sb.WriteString(";")
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// genContractNo 生成合同编号:HT-YYYYMMDD-4位随机,租户内查重,最多重试 5 次。
|
||||
|
||||
@@ -195,6 +195,7 @@ func crmSumEntities() map[string]dashEntity {
|
||||
"projectAmount": {"yz_backend_crm_project", "create_time", ""},
|
||||
"contractAmount": {"yz_backend_crm_contract", "create_time", ""},
|
||||
"chanceAmount": {"yz_backend_crm_business", "create_time", ""},
|
||||
"clueAmount": {"yz_backend_crm_clue", "create_time", ""},
|
||||
"paymentAmount": {"yz_backend_crm_payback", "create_time", ""},
|
||||
}
|
||||
}
|
||||
@@ -207,6 +208,8 @@ func crmAmountCol(key string) string {
|
||||
return "total_amount"
|
||||
case "chanceAmount":
|
||||
return "amount"
|
||||
case "clueAmount":
|
||||
return "amount"
|
||||
case "paymentAmount":
|
||||
return "received_amount"
|
||||
}
|
||||
@@ -240,7 +243,7 @@ func (c *BackendCrmDashboardController) Summary() {
|
||||
sums := crmSumEntities()
|
||||
statKeys := []string{
|
||||
"newCustomer", "newContact", "newProject", "newContract", "newClue",
|
||||
"newChance", "projectAmount", "contractAmount", "chanceAmount", "paymentAmount", "newVisit",
|
||||
"newChance", "clueAmount", "chanceAmount", "projectAmount", "paymentAmount", "newVisit",
|
||||
}
|
||||
|
||||
stats := map[string]map[string]float64{}
|
||||
|
||||
@@ -60,10 +60,12 @@ type paybackPayload struct {
|
||||
ReceiptName string `json:"receipt_name"` // 流水回执文件名(选填)
|
||||
}
|
||||
|
||||
// paybackResp 列表 / 详情响应:items 解析为 JSON 数组透出。
|
||||
// paybackResp 列表 / 详情响应:items 解析为 JSON 数组透出;
|
||||
// children 为回款明细(树形子集:该计划下一笔笔实际回款记录)。
|
||||
type paybackResp struct {
|
||||
models.TenantCrmPayback
|
||||
Items json.RawMessage `json:"items"`
|
||||
Items json.RawMessage `json:"items"`
|
||||
Children []models.TenantCrmPaybackRecord `json:"children"`
|
||||
}
|
||||
|
||||
// paybackProgress 回款进度行(跨计划摊平的计划明细)。
|
||||
@@ -109,6 +111,8 @@ func (c *BackendCrmPaybackController) List() {
|
||||
|
||||
tenantID := pipelineTenantID(claims)
|
||||
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
|
||||
// 数据范围:平台/租户管理员可见全部,其他用户仅本人(负责人或创建人)
|
||||
cond = crmApplyOwnerScope(cond, claims)
|
||||
if keyword != "" {
|
||||
cond = cond.AndCond(orm.NewCondition().
|
||||
Or("contract_name__contains", keyword).
|
||||
@@ -138,9 +142,32 @@ func (c *BackendCrmPaybackController) List() {
|
||||
// 排序:进行中(1) 在前、已完成(2) 在后,同状态内按最新创建优先
|
||||
_, _ = qs.OrderBy("status", "-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
|
||||
}
|
||||
// 组装树形数据:每个回款计划挂上它的回款记录作为明细子集
|
||||
planIDs := make([]uint64, 0, len(list))
|
||||
for i := range list {
|
||||
planIDs = append(planIDs, list[i].ID)
|
||||
}
|
||||
recordsByPlan := make(map[uint64][]models.TenantCrmPaybackRecord, len(list))
|
||||
if len(planIDs) > 0 {
|
||||
var recs []models.TenantCrmPaybackRecord
|
||||
_, _ = models.Orm.QueryTable(new(models.TenantCrmPaybackRecord)).
|
||||
Filter("tenant_id", tenantID).
|
||||
Filter("payback_id__in", planIDs).
|
||||
Filter("delete_time__isnull", true).
|
||||
OrderBy("-received_date", "-id").All(&recs)
|
||||
for _, r := range recs {
|
||||
recordsByPlan[r.PaybackID] = append(recordsByPlan[r.PaybackID], r)
|
||||
}
|
||||
}
|
||||
items := make([]paybackResp, 0, len(list))
|
||||
for i := range list {
|
||||
items = append(items, buildPaybackResp(&list[i]))
|
||||
resp := buildPaybackResp(&list[i])
|
||||
if recs := recordsByPlan[list[i].ID]; len(recs) > 0 {
|
||||
resp.Children = recs
|
||||
} else {
|
||||
resp.Children = []models.TenantCrmPaybackRecord{}
|
||||
}
|
||||
items = append(items, resp)
|
||||
}
|
||||
|
||||
// 汇总(当前筛选条件下全部计划的计划总额 / 已回款 / 待回款),用于列表页统计卡片
|
||||
@@ -271,6 +298,11 @@ func (c *BackendCrmPaybackController) Update() {
|
||||
pipelineErr(&c.Controller, 404, 404, "回款计划未找到")
|
||||
return
|
||||
}
|
||||
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
|
||||
if !canAccessCrmRecord(claims, row.OwnerUserID, row.CreateUserID) {
|
||||
pipelineErr(&c.Controller, 403, 403, "无权操作该回款计划数据")
|
||||
return
|
||||
}
|
||||
|
||||
if p.ContractID > 0 {
|
||||
row.ContractID = &p.ContractID
|
||||
@@ -302,20 +334,23 @@ func (c *BackendCrmPaybackController) Update() {
|
||||
row.Status = pickInt8(p.Status, 1, 2)
|
||||
}
|
||||
if p.Items != nil {
|
||||
itemsJSON, _, err := normalizePaybackItems(p.Items)
|
||||
itemsJSON, total, err := normalizePaybackItems(p.Items)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 400, 400, "计划明细格式错误")
|
||||
return
|
||||
}
|
||||
row.Items = itemsJSON
|
||||
row.TotalAmount = total // 计划总额随明细重算
|
||||
}
|
||||
applyPaybackReceived(&row)
|
||||
row.UpdateTime = time.Now()
|
||||
|
||||
if _, err := models.Orm.Update(&row); err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
// 已回款金额一律以「回款记录」为准重新汇总:
|
||||
// 明细里不承载实收数据,若此处按明细重算,编辑计划(如调整回款日期)会把已回款冲成 0。
|
||||
refreshPlanReceived(tenantID, row.ID)
|
||||
crmWriteLog(tenantID, 4, row.ID, "update", "更新回款计划:"+row.ContractName, claims)
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID})
|
||||
}
|
||||
@@ -340,10 +375,20 @@ func (c *BackendCrmPaybackController) Delete() {
|
||||
pipelineErr(&c.Controller, 404, 404, "回款计划未找到")
|
||||
return
|
||||
}
|
||||
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
|
||||
if !canAccessCrmRecord(claims, row.OwnerUserID, row.CreateUserID) {
|
||||
pipelineErr(&c.Controller, 403, 403, "无权操作该回款计划数据")
|
||||
return
|
||||
}
|
||||
if !canDeleteCrmRecord(claims, row.CreateUserID) {
|
||||
pipelineErr(&c.Controller, 403, 403, "只有创建人、租户管理员或平台管理员可以删除该回款计划")
|
||||
return
|
||||
}
|
||||
// 已完成(回款已收齐)的回款计划不允许删除
|
||||
if row.Status == 2 {
|
||||
pipelineErr(&c.Controller, 400, 400, "该回款计划已完成(回款已收齐),不允许删除")
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
_, err = models.Orm.QueryTable(new(models.TenantCrmPayback)).
|
||||
Filter("id", id).Filter("tenant_id", tenantID).
|
||||
@@ -369,6 +414,8 @@ func (c *BackendCrmPaybackController) ProgressList() {
|
||||
contractID := strings.TrimSpace(c.GetString("contract_id"))
|
||||
|
||||
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
|
||||
// 数据范围:平台/租户管理员可见全部,其他用户仅本人(负责人或创建人)
|
||||
cond = crmApplyOwnerScope(cond, claims)
|
||||
if keyword != "" {
|
||||
cond = cond.AndCond(orm.NewCondition().
|
||||
Or("contract_name__contains", keyword).
|
||||
@@ -456,6 +503,11 @@ func (c *BackendCrmPaybackController) Receive() {
|
||||
pipelineErr(&c.Controller, 404, 404, "回款计划未找到")
|
||||
return
|
||||
}
|
||||
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
|
||||
if !canAccessCrmRecord(claims, row.OwnerUserID, row.CreateUserID) {
|
||||
pipelineErr(&c.Controller, 403, 403, "无权操作该回款计划数据")
|
||||
return
|
||||
}
|
||||
items := parsePaybackItems(row.Items)
|
||||
matched := false
|
||||
for i := range items {
|
||||
@@ -664,6 +716,8 @@ func (c *BackendCrmPaybackController) RecordList() {
|
||||
paybackID, _ := c.GetUint64("payback_id")
|
||||
|
||||
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
|
||||
// 数据范围:平台/租户管理员可见全部,其他用户仅本人(负责人或创建人)
|
||||
cond = crmApplyOwnerScope(cond, claims)
|
||||
if contractID > 0 {
|
||||
cond = cond.And("contract_id", contractID)
|
||||
}
|
||||
@@ -678,6 +732,7 @@ func (c *BackendCrmPaybackController) RecordList() {
|
||||
// (已回款由回款记录汇总回写,兼容历史计划)
|
||||
var totalPlanned, totalReceived float64
|
||||
planCond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
|
||||
planCond = crmApplyOwnerScope(planCond, claims)
|
||||
if contractID > 0 {
|
||||
planCond = planCond.And("contract_id", contractID)
|
||||
}
|
||||
@@ -792,6 +847,14 @@ func (c *BackendCrmPaybackController) DeleteRecord() {
|
||||
pipelineErr(&c.Controller, 404, 404, "回款记录不存在")
|
||||
return
|
||||
}
|
||||
// 所属回款计划已完成(回款已收齐)时不允许删除回款记录,避免破坏已完成状态
|
||||
var plan models.TenantCrmPayback
|
||||
if err := models.Orm.QueryTable(new(models.TenantCrmPayback)).
|
||||
Filter("id", row.PaybackID).Filter("tenant_id", tenantID).
|
||||
Filter("delete_time__isnull", true).One(&plan); err == nil && plan.Status == 2 {
|
||||
pipelineErr(&c.Controller, 400, 400, "所属回款计划已完成(回款已收齐),不允许删除回款记录")
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
if _, err := models.Orm.QueryTable(new(models.TenantCrmPaybackRecord)).
|
||||
Filter("id", id).Filter("tenant_id", tenantID).
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
)
|
||||
|
||||
// CRM 客户 / 供应商的删除权限规则:
|
||||
@@ -70,3 +72,67 @@ func isTenantAdmin(userID int, tenantID int) bool {
|
||||
}
|
||||
return rights == "" || rights == "[]" || rights == "null"
|
||||
}
|
||||
|
||||
// ========================== CRM 数据范围(用户级隔离) ==========================
|
||||
//
|
||||
// 数据可见范围约定(仅作用于用户级业务数据):
|
||||
// - 平台管理员 / 租户管理员(含未分配角色、角色未配置权限等全权限情形)→ 可见租户内全部数据;
|
||||
// - 其他用户 → 仅可见「负责人是自己」或「由自己创建」的数据。
|
||||
//
|
||||
// 租户共享数据不在此列,始终对全租户可见:供应商、客户公海、产品列表、产品分类。
|
||||
// 后续如需按角色配置数据范围,只需扩展 crmScopeSelfOnly 的判定。
|
||||
|
||||
// crmScopeSelfOnly 判断当前用户是否只能看「本人」数据(true=仅本人)。
|
||||
func crmScopeSelfOnly(claims *jwtutil.Claims) bool {
|
||||
if claims == nil {
|
||||
return true
|
||||
}
|
||||
if claims.UserType == "platform" {
|
||||
return false
|
||||
}
|
||||
return !isTenantAdmin(claims.UserID, claims.TenantId)
|
||||
}
|
||||
|
||||
// crmApplyOwnerScope 为列表查询条件追加数据范围限制(需要「仅本人」时)。
|
||||
// 需要用户级隔离的模块请在构造完 tenant 条件后调用。
|
||||
func crmApplyOwnerScope(cond *orm.Condition, claims *jwtutil.Claims) *orm.Condition {
|
||||
if !crmScopeSelfOnly(claims) {
|
||||
return cond
|
||||
}
|
||||
uid := pipelineUID(claims)
|
||||
return cond.AndCond(orm.NewCondition().
|
||||
Or("owner_user_id", uid).
|
||||
Or("create_user_id", uid))
|
||||
}
|
||||
|
||||
// canAccessCrmRecord 判断当前用户是否有权访问(查看 / 编辑 / 删除)指定记录。
|
||||
// ownerUserID / createUserID 为记录的负责人与创建人(历史数据可能为空)。
|
||||
func canAccessCrmRecord(claims *jwtutil.Claims, ownerUserID, createUserID string) bool {
|
||||
if claims == nil {
|
||||
return false
|
||||
}
|
||||
// 平台 / 租户管理员可访问全部
|
||||
if !crmScopeSelfOnly(claims) {
|
||||
return true
|
||||
}
|
||||
uid := pipelineUID(claims)
|
||||
if ownerUserID != "" && ownerUserID == uid {
|
||||
return true
|
||||
}
|
||||
return createUserID != "" && createUserID == uid
|
||||
}
|
||||
|
||||
// crmNameDuplicated 判断同租户内是否已存在同名记录(不含软删除),用于创建时的唯一性校验。
|
||||
// model 传 new(models.XXX);nameField 为数据库列名(如 clue_name)。
|
||||
func crmNameDuplicated(model interface{}, tenantID, nameField, name string) bool {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || strings.TrimSpace(tenantID) == "" || models.Orm == nil {
|
||||
return false
|
||||
}
|
||||
cnt, err := models.Orm.QueryTable(model).
|
||||
Filter("tenant_id", tenantID).
|
||||
Filter("delete_time__isnull", true).
|
||||
Filter(nameField, name).
|
||||
Count()
|
||||
return err == nil && cnt > 0
|
||||
}
|
||||
|
||||
@@ -146,6 +146,13 @@ func (c *BackendCrmProductController) Create() {
|
||||
if lineType != "dev" {
|
||||
lineType = "product"
|
||||
}
|
||||
// 非开发行不保留开发字段,避免残留(如 dev_tree="[]")导致清单调用时被误判为软件开发行
|
||||
devUnitPrice := p.DevUnitPrice
|
||||
devTree := string(p.DevTree)
|
||||
if lineType != "dev" {
|
||||
devUnitPrice = 0
|
||||
devTree = ""
|
||||
}
|
||||
now := time.Now()
|
||||
row := models.TenantCrmProduct{
|
||||
TenantID: pipelineTenantID(claims),
|
||||
@@ -158,8 +165,8 @@ func (c *BackendCrmProductController) Create() {
|
||||
CostPrice: p.CostPrice,
|
||||
TaxRate: p.TaxRate,
|
||||
LineType: lineType,
|
||||
DevUnitPrice: p.DevUnitPrice,
|
||||
DevTree: string(p.DevTree),
|
||||
DevUnitPrice: devUnitPrice,
|
||||
DevTree: devTree,
|
||||
Status: status,
|
||||
Remark: p.Remark,
|
||||
CreateUserID: pipelineUID(claims),
|
||||
@@ -212,24 +219,31 @@ func (c *BackendCrmProductController) Update() {
|
||||
if lineType != "dev" {
|
||||
lineType = "product"
|
||||
}
|
||||
// 非开发行清空开发字段,避免残留导致清单调用时被误判为软件开发行
|
||||
devUnitPrice := p.DevUnitPrice
|
||||
devTree := string(p.DevTree)
|
||||
if lineType != "dev" {
|
||||
devUnitPrice = 0
|
||||
devTree = ""
|
||||
}
|
||||
now := time.Now()
|
||||
_, err = models.Orm.QueryTable(new(models.TenantCrmProduct)).
|
||||
Filter("id", id).Filter("tenant_id", tenantID).
|
||||
Update(orm.Params{
|
||||
"product_no": strings.TrimSpace(p.ProductNo),
|
||||
"product_name": strings.TrimSpace(p.ProductName),
|
||||
"category": strings.TrimSpace(p.Category),
|
||||
"unit": strings.TrimSpace(p.Unit),
|
||||
"spec": strings.TrimSpace(p.Spec),
|
||||
"price": p.Price,
|
||||
"cost_price": p.CostPrice,
|
||||
"tax_rate": p.TaxRate,
|
||||
"line_type": lineType,
|
||||
"dev_unit_price": p.DevUnitPrice,
|
||||
"dev_tree": p.DevTree,
|
||||
"status": status,
|
||||
"remark": p.Remark,
|
||||
"update_time": now,
|
||||
"product_no": strings.TrimSpace(p.ProductNo),
|
||||
"product_name": strings.TrimSpace(p.ProductName),
|
||||
"category": strings.TrimSpace(p.Category),
|
||||
"unit": strings.TrimSpace(p.Unit),
|
||||
"spec": strings.TrimSpace(p.Spec),
|
||||
"price": p.Price,
|
||||
"cost_price": p.CostPrice,
|
||||
"tax_rate": p.TaxRate,
|
||||
"line_type": lineType,
|
||||
"dev_unit_price": devUnitPrice,
|
||||
"dev_tree": devTree,
|
||||
"status": status,
|
||||
"remark": p.Remark,
|
||||
"update_time": now,
|
||||
})
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error())
|
||||
|
||||
@@ -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