优化项目管理

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
+48 -20
View File
@@ -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 {
+31 -1
View File
@@ -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
+109 -22
View File
@@ -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 次。
+4 -1
View File
@@ -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{}
+68 -5
View File
@@ -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).
+66
View File
@@ -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
}
+30 -16
View File
@@ -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())
+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