package controllers import ( "fmt" "strings" "time" "server/models" beego "github.com/beego/beego/v2/server/web" ) // BackendCrmDashboardController CRM 数据仪表盘:按时间维度聚合真实业务数据。 type BackendCrmDashboardController struct { beego.Controller } /* ------------------------------ 时间维度 ------------------------------ */ // crmPeriod 返回当前周期与上一个周期的起止时间(含当天)。 func crmPeriod(r string) (curStart, curEnd, prevStart, prevEnd time.Time) { now := time.Now() y, m, d := now.Date() loc := now.Location() switch r { case "day": curStart = time.Date(y, m, d, 0, 0, 0, 0, loc) curEnd = curStart.Add(24*time.Hour - time.Second) prevStart = curStart.AddDate(0, 0, -1) prevEnd = curEnd.AddDate(0, 0, -1) case "week": wd := int(now.Weekday()) // 0 周日 ~ 6 周六 if wd == 0 { wd = 7 } monday := time.Date(y, m, d, 0, 0, 0, 0, loc).AddDate(0, 0, -(wd - 1)) curStart = monday curEnd = monday.AddDate(0, 0, 7).Add(-time.Second) prevStart = monday.AddDate(0, 0, -7) prevEnd = curStart.Add(-time.Second) case "month": curStart = time.Date(y, m, 1, 0, 0, 0, 0, loc) curEnd = time.Date(y, m+1, 1, 0, 0, 0, 0, loc).Add(-time.Second) prevStart = time.Date(y, m-1, 1, 0, 0, 0, 0, loc) prevEnd = curStart.Add(-time.Second) case "quarter": q := (int(m) - 1) / 3 qs := time.Date(y, time.Month(q*3+1), 1, 0, 0, 0, 0, loc) curStart = qs curEnd = qs.AddDate(0, 3, 0).Add(-time.Second) prevStart = qs.AddDate(0, -3, 0) prevEnd = curStart.Add(-time.Second) default: // year curStart = time.Date(y, 1, 1, 0, 0, 0, 0, loc) curEnd = time.Date(y+1, 1, 1, 0, 0, 0, 0, loc).Add(-time.Second) prevStart = time.Date(y-1, 1, 1, 0, 0, 0, 0, loc) prevEnd = curStart.Add(-time.Second) } return } // crmBucketExpr 按时间维度返回分桶表达式(0..n-1 的整数桶序号)。 func crmBucketExpr(r, dateCol string) string { switch r { case "day": return fmt.Sprintf("FLOOR(HOUR(%s)/2)", dateCol) // 每 2 小时一桶,0~11 case "week": return fmt.Sprintf("WEEKDAY(%s)", dateCol) // 周一=0 ~ 周日=6 case "month": return fmt.Sprintf("DAY(%s)-1", dateCol) // 1日=0 case "quarter": return fmt.Sprintf("(MONTH(%s)-1) %% 3", dateCol) // 季度内月序号 0~2 default: return fmt.Sprintf("MONTH(%s)-1", dateCol) // 年内月序号 0~11 } } // crmAxis 返回与分桶对齐的 X 轴文案。 func crmAxis(r string, start time.Time) []string { loc := start.Location() switch r { case "day": ax := make([]string, 12) for i := 0; i < 12; i++ { ax[i] = fmt.Sprintf("%02d:00", i*2) } return ax case "week": return []string{"周一", "周二", "周三", "周四", "周五", "周六", "周日"} case "month": last := time.Date(start.Year(), start.Month()+1, 0, 0, 0, 0, 0, loc).Day() ax := make([]string, last) for i := 0; i < last; i++ { ax[i] = fmt.Sprintf("%d日", i+1) } return ax case "quarter": q := (int(start.Month()) - 1) / 3 ax := make([]string, 3) for i := 0; i < 3; i++ { ax[i] = fmt.Sprintf("%d月", q*3+i+1) } return ax default: ax := make([]string, 12) for i := 0; i < 12; i++ { ax[i] = fmt.Sprintf("%d月", i+1) } return ax } } /* ------------------------------ 聚合查询 ------------------------------ */ func crmCountWhere(tenantID, table, dateCol, extra string, start, end time.Time) float64 { var n int64 sql := fmt.Sprintf( "SELECT COUNT(*) FROM %s WHERE tenant_id=? AND delete_time IS NULL AND %s BETWEEN ? AND ? %s", table, dateCol, extra, ) if err := models.Orm.Raw(sql, tenantID, start, end).QueryRow(&n); err != nil { return 0 } return float64(n) } func crmSumWhere(tenantID, table, dateCol, sumCol, extra string, start, end time.Time) float64 { var s float64 sql := fmt.Sprintf( "SELECT COALESCE(SUM(%s),0) FROM %s WHERE tenant_id=? AND delete_time IS NULL AND %s BETWEEN ? AND ? %s", sumCol, table, dateCol, extra, ) if err := models.Orm.Raw(sql, tenantID, start, end).QueryRow(&s); err != nil { return 0 } return s } // crmSeriesWhere 按时间分桶返回序列(长度 = bucketCount,缺失桶补 0)。 func crmSeriesWhere(tenantID, table, dateCol, metric, sumCol, extra, bucketExpr string, bucketCount int, start, end time.Time) []float64 { out := make([]float64, bucketCount) var rows []struct { B int `orm:"column(b)"` V float64 `orm:"column(v)"` } var sel string if metric == "sum" { sel = fmt.Sprintf("SELECT %s AS b, COALESCE(SUM(%s),0) AS v", bucketExpr, sumCol) } else { sel = fmt.Sprintf("SELECT %s AS b, COUNT(*) AS v", bucketExpr) } sql := fmt.Sprintf( "%s FROM %s WHERE tenant_id=? AND delete_time IS NULL AND %s BETWEEN ? AND ? %s GROUP BY b", sel, table, dateCol, extra, ) if _, err := models.Orm.Raw(sql, tenantID, start, end).QueryRows(&rows); err != nil { return out } for _, row := range rows { if row.B >= 0 && row.B < bucketCount { out[row.B] = row.V } } return out } func crmRound1(v float64) float64 { return float64(int64(v*10+0.5)) / 10 } /* ------------------------------ 实体定义 ------------------------------ */ type dashEntity struct { table string dateCol string extra string } // crmCountEntities 计数的实体(新增客户/联系人/项目/合同/线索/商机/回访)。 func crmCountEntities() map[string]dashEntity { return map[string]dashEntity{ "newCustomer": {"yz_backend_erp_customer", "create_time", " AND in_pool=0 AND is_draft=0"}, "newContact": {"yz_backend_contact_company", "create_time", " AND company_type='customer'"}, "newProject": {"yz_backend_crm_project", "create_time", ""}, "newContract": {"yz_backend_crm_contract", "create_time", ""}, "newClue": {"yz_backend_crm_clue", "create_time", ""}, "newChance": {"yz_backend_crm_business", "create_time", ""}, "newVisit": {"yz_backend_crm_follow", "follow_time", " AND follow_time IS NOT NULL"}, } } // crmSumEntities 求和的实体(各金额)。 func crmSumEntities() map[string]dashEntity { return map[string]dashEntity{ "projectAmount": {"yz_backend_crm_project", "create_time", ""}, "contractAmount": {"yz_backend_crm_contract", "create_time", ""}, "chanceAmount": {"yz_backend_crm_business", "create_time", ""}, "paymentAmount": {"yz_backend_crm_payback", "create_time", ""}, } } func crmAmountCol(key string) string { switch key { case "projectAmount": return "amount" case "contractAmount": return "total_amount" case "chanceAmount": return "amount" case "paymentAmount": return "received_amount" } return "amount" } /* ------------------------------ 主接口 ------------------------------ */ // Summary GET /backend/crm/dashboard?range=day|week|month|quarter|year // 返回仪表盘所需的全部真实统计数据。 func (c *BackendCrmDashboardController) Summary() { claims, err := pipelineClaims(&c.Controller) if err != nil { pipelineErr(&c.Controller, 401, 401, err.Error()) return } tenantID := pipelineTenantID(claims) r := strings.TrimSpace(c.GetString("range")) valid := map[string]bool{"day": true, "week": true, "month": true, "quarter": true, "year": true} if !valid[r] { r = "month" } curStart, curEnd, prevStart, prevEnd := crmPeriod(r) axis := crmAxis(r, curStart) bucketCount := len(axis) bucketExpr := crmBucketExpr(r, "create_time") counts := crmCountEntities() sums := crmSumEntities() statKeys := []string{ "newCustomer", "newContact", "newProject", "newContract", "newClue", "newChance", "projectAmount", "contractAmount", "chanceAmount", "paymentAmount", "newVisit", } stats := map[string]map[string]float64{} for _, k := range statKeys { var cur, prev float64 if e, ok := counts[k]; ok { cur = crmCountWhere(tenantID, e.table, e.dateCol, e.extra, curStart, curEnd) prev = crmCountWhere(tenantID, e.table, e.dateCol, e.extra, prevStart, prevEnd) } else if e, ok := sums[k]; ok { col := crmAmountCol(k) cur = crmSumWhere(tenantID, e.table, e.dateCol, col, e.extra, curStart, curEnd) prev = crmSumWhere(tenantID, e.table, e.dateCol, col, e.extra, prevStart, prevEnd) } var trend float64 if prev > 0 { trend = (cur - prev) / prev * 100 } else if cur > 0 { trend = 100 } stats[k] = map[string]float64{"value": crmRound1(cur), "trend": crmRound1(trend)} } // 趋势图:新增客户 / 新增商机 按桶分布 customerSeries := crmSeriesWhere(tenantID, counts["newCustomer"].table, counts["newCustomer"].dateCol, "count", "", counts["newCustomer"].extra, crmBucketExpr(r, counts["newCustomer"].dateCol), bucketCount, curStart, curEnd) chanceSeries := crmSeriesWhere(tenantID, counts["newChance"].table, counts["newChance"].dateCol, "count", "", counts["newChance"].extra, crmBucketExpr(r, counts["newChance"].dateCol), bucketCount, curStart, curEnd) // 金额对比图:合同金额 / 回款金额 按桶分布 contractSeries := crmSeriesWhere(tenantID, sums["contractAmount"].table, sums["contractAmount"].dateCol, "sum", "total_amount", sums["contractAmount"].extra, bucketExpr, bucketCount, curStart, curEnd) paymentSeries := crmSeriesWhere(tenantID, sums["paymentAmount"].table, sums["paymentAmount"].dateCol, "sum", "received_amount", sums["paymentAmount"].extra, crmBucketExpr(r, sums["paymentAmount"].dateCol), bucketCount, curStart, curEnd) // 销售漏斗:线索 / 商机 / 合同 / 回款(期内各实体新增数) funnel := []map[string]interface{}{ {"name": "线索", "value": crmCountWhere(tenantID, counts["newClue"].table, counts["newClue"].dateCol, counts["newClue"].extra, curStart, curEnd)}, {"name": "商机", "value": crmCountWhere(tenantID, counts["newChance"].table, counts["newChance"].dateCol, counts["newChance"].extra, curStart, curEnd)}, {"name": "合同", "value": crmCountWhere(tenantID, counts["newContract"].table, counts["newContract"].dateCol, counts["newContract"].extra, curStart, curEnd)}, {"name": "回款", "value": crmCountWhere(tenantID, sums["paymentAmount"].table, sums["paymentAmount"].dateCol, sums["paymentAmount"].extra, curStart, curEnd)}, } // 线索来源分布 var srcRows []struct { Source string `orm:"column(source)"` Cnt float64 `orm:"column(cnt)"` } srcSQL := "SELECT source, COUNT(*) AS cnt FROM yz_backend_crm_clue WHERE tenant_id=? AND delete_time IS NULL AND create_time BETWEEN ? AND ? GROUP BY source ORDER BY cnt DESC" _, _ = models.Orm.Raw(srcSQL, tenantID, curStart, curEnd).QueryRows(&srcRows) source := make([]map[string]interface{}, 0, len(srcRows)) for _, row := range srcRows { name := strings.TrimSpace(row.Source) if name == "" { name = "未填写" } source = append(source, map[string]interface{}{"name": name, "value": row.Cnt}) } // 销售业绩排行:按负责人合同金额 Top8,并补齐回款金额 var rankRows []struct { OwnerUserID string `orm:"column(owner_user_id)"` OwnerUserName string `orm:"column(owner_user_name)"` Contract float64 `orm:"column(contract)"` } rankSQL := "SELECT owner_user_id, owner_user_name, COALESCE(SUM(total_amount),0) AS contract FROM yz_backend_crm_contract WHERE tenant_id=? AND delete_time IS NULL AND create_time BETWEEN ? AND ? GROUP BY owner_user_id, owner_user_name ORDER BY contract DESC LIMIT 8" _, _ = models.Orm.Raw(rankSQL, tenantID, curStart, curEnd).QueryRows(&rankRows) paymentMap := map[string]float64{} var payRows []struct { OwnerUserID string `orm:"column(owner_user_id)"` Payment float64 `orm:"column(payment)"` } paySQL := "SELECT owner_user_id, COALESCE(SUM(received_amount),0) AS payment FROM yz_backend_crm_payback WHERE tenant_id=? AND delete_time IS NULL AND create_time BETWEEN ? AND ? GROUP BY owner_user_id" _, _ = models.Orm.Raw(paySQL, tenantID, curStart, curEnd).QueryRows(&payRows) for _, row := range payRows { paymentMap[row.OwnerUserID] = row.Payment } rank := make([]map[string]interface{}, 0, len(rankRows)) for _, row := range rankRows { name := strings.TrimSpace(row.OwnerUserName) if name == "" { name = "未知" } rank = append(rank, map[string]interface{}{ "name": name, "dept": "", "contract": crmRound1(row.Contract), "payment": crmRound1(paymentMap[row.OwnerUserID]), }) } // 最新回访 var visitRows []struct { RelatedName string `orm:"column(related_name)"` FollowType string `orm:"column(follow_type)"` Content string `orm:"column(content)"` OwnerUserName string `orm:"column(owner_user_name)"` FollowTime *time.Time `orm:"column(follow_time)"` } visitSQL := "SELECT related_name, follow_type, content, owner_user_name, follow_time FROM yz_backend_crm_follow WHERE tenant_id=? AND delete_time IS NULL AND follow_time IS NOT NULL ORDER BY follow_time DESC LIMIT 6" _, _ = models.Orm.Raw(visitSQL, tenantID).QueryRows(&visitRows) visits := make([]map[string]interface{}, 0, len(visitRows)) for _, row := range visitRows { way := strings.TrimSpace(row.FollowType) if way == "" { way = "其他" } user := strings.TrimSpace(row.OwnerUserName) if user == "" { user = "未知" } timeStr := "" if row.FollowTime != nil { timeStr = row.FollowTime.Format("01-02 15:04") } visits = append(visits, map[string]interface{}{ "customer": row.RelatedName, "way": way, "content": row.Content, "user": user, "time": timeStr, }) } pipelineOk(&c.Controller, map[string]interface{}{ "range": r, "stats": stats, "trend": map[string]interface{}{ "axis": axis, "customer": customerSeries, "chance": chanceSeries, }, "funnel": funnel, "amount": map[string]interface{}{ "axis": axis, "contract": contractSeries, "payment": paymentSeries, }, "source": source, "rank": rank, "visits": visits, }) }