391 lines
14 KiB
Go
391 lines
14 KiB
Go
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", ""},
|
|
"clueAmount": {"yz_backend_crm_clue", "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 "clueAmount":
|
|
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", "clueAmount", "chanceAmount", "projectAmount", "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,
|
|
})
|
|
}
|