From dc97690eb1071adc51ba05e6e45550005ce25f6f Mon Sep 17 00:00:00 2001 From: Yvan <8574526@qq,com> Date: Thu, 5 Nov 2026 01:10:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E5=B0=8F=E7=BB=84?= =?UTF-8?q?=E7=BB=A9=E6=95=88=E4=B8=8E=E6=9C=89=E6=95=88=E7=AB=AF=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1=E5=B9=B6=E4=BF=AE=E5=A4=8D=E5=AE=A1=E6=9F=A5=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/alert_trigger_http_test.go | 2 +- cmd/analytics_http_test.go | 2 +- cmd/business_http_test.go | 2 +- cmd/business_management_http_test.go | 168 ++++++ cmd/channel_http_test.go | 2 +- cmd/other_settings_http_test.go | 2 +- cmd/report_build_http_test.go | 6 +- cmd/wire_gen.go | 109 ++-- internal/biz/biz.go | 2 +- internal/biz/business/account.go | 4 + internal/biz/business/analytics.go | 39 +- internal/biz/business/analytics_test.go | 2 +- internal/biz/business/channel.go | 24 +- internal/biz/business/channel_test.go | 2 +- internal/biz/business/dashboard.go | 104 ++++ internal/biz/business/effectiveness.go | 148 +++++ internal/biz/business/goal_metrics.go | 89 +++ internal/biz/business/management_test.go | 355 ++++++++++++ internal/biz/business/other_settings.go | 12 +- internal/biz/business/other_settings_test.go | 4 +- internal/biz/business/performance.go | 528 ++++++++++++++++++ internal/biz/business/performance_goal.go | 220 ++++++++ internal/biz/business/performance_models.go | 139 +++++ internal/biz/business/team.go | 351 ++++++++++++ internal/data/business/account.go | 5 + internal/data/business/analytics.go | 17 + internal/data/business/channel.go | 15 +- internal/data/business/dashboard.go | 94 ++++ internal/data/business/effectiveness.go | 198 +++++++ internal/data/business/management_models.go | 91 +++ internal/data/business/management_test.go | 239 ++++++++ internal/data/business/migrations.go | 2 +- internal/data/business/performance.go | 232 ++++++++ internal/data/business/performance_goal.go | 32 ++ internal/data/business/team.go | 172 ++++++ internal/data/data.go | 5 + internal/modules/business/definition.go | 62 +- internal/modules/business/definition_test.go | 3 +- internal/modules/catalog_test.go | 12 +- internal/routecatalog/catalog.go | 11 + internal/server/handler/performance.go | 139 +++++ internal/server/handler/provider.go | 1 + internal/server/handler/set.go | 77 +-- internal/server/router/performance.go | 20 + internal/server/router/routes.go | 1 + internal/service/business/account.go | 14 +- internal/service/business/account_test.go | 20 +- internal/service/business/analytics.go | 1 + internal/service/business/channel.go | 4 +- internal/service/business/performance.go | 296 ++++++++++ .../service/business/performance_excel.go | 145 +++++ internal/service/business/performance_test.go | 137 +++++ internal/service/dto/analytics.go | 1 + internal/service/dto/business_account.go | 1 + internal/service/dto/channel.go | 4 + internal/service/dto/performance.go | 182 ++++++ internal/service/service.go | 1 + internal/worker/task_registry.go | 39 +- web/src/api/performance.js | 13 + web/src/pathInfo.json | 1 + .../components/BusinessDashboard.vue | 22 +- web/src/view/sw/BusinessList.vue | 140 +---- web/src/view/sw/BusinessPerformance.vue | 128 +++++ web/src/view/sw/ChannelList.vue | 7 +- web/src/view/sw/OtherSettings.vue | 4 +- .../view/sw/components/BusinessManagement.vue | 137 +++++ web/src/view/sw/components/GoalDrawer.vue | 51 ++ web/src/view/sw/components/MyGoalCard.vue | 29 + .../sw/components/PerformanceDetailDrawer.vue | 29 + .../view/sw/components/PerformanceScroll.vue | 35 ++ .../view/sw/components/PerformanceTrend.vue | 42 ++ .../view/sw/components/ReportAnalytics.vue | 1 + web/src/view/sw/components/TeamSettings.vue | 65 +++ web/src/view/sw/performance-display.js | 18 + web/src/view/sw/performance-display.test.js | 18 + 75 files changed, 5033 insertions(+), 296 deletions(-) create mode 100644 cmd/business_management_http_test.go create mode 100644 internal/biz/business/dashboard.go create mode 100644 internal/biz/business/effectiveness.go create mode 100644 internal/biz/business/goal_metrics.go create mode 100644 internal/biz/business/management_test.go create mode 100644 internal/biz/business/performance.go create mode 100644 internal/biz/business/performance_goal.go create mode 100644 internal/biz/business/performance_models.go create mode 100644 internal/biz/business/team.go create mode 100644 internal/data/business/dashboard.go create mode 100644 internal/data/business/effectiveness.go create mode 100644 internal/data/business/management_models.go create mode 100644 internal/data/business/management_test.go create mode 100644 internal/data/business/performance.go create mode 100644 internal/data/business/performance_goal.go create mode 100644 internal/data/business/team.go create mode 100644 internal/server/handler/performance.go create mode 100644 internal/server/router/performance.go create mode 100644 internal/service/business/performance.go create mode 100644 internal/service/business/performance_excel.go create mode 100644 internal/service/business/performance_test.go create mode 100644 internal/service/dto/performance.go create mode 100644 web/src/api/performance.js create mode 100644 web/src/view/sw/BusinessPerformance.vue create mode 100644 web/src/view/sw/components/BusinessManagement.vue create mode 100644 web/src/view/sw/components/GoalDrawer.vue create mode 100644 web/src/view/sw/components/MyGoalCard.vue create mode 100644 web/src/view/sw/components/PerformanceDetailDrawer.vue create mode 100644 web/src/view/sw/components/PerformanceScroll.vue create mode 100644 web/src/view/sw/components/PerformanceTrend.vue create mode 100644 web/src/view/sw/components/TeamSettings.vue create mode 100644 web/src/view/sw/performance-display.js create mode 100644 web/src/view/sw/performance-display.test.js diff --git a/cmd/alert_trigger_http_test.go b/cmd/alert_trigger_http_test.go index eb4af81..39a89f7 100644 --- a/cmd/alert_trigger_http_test.go +++ b/cmd/alert_trigger_http_test.go @@ -53,7 +53,7 @@ func TestAlertTriggerThroughNativeTaskHTTP(t *testing.T) { registry := platformtask.NewRegistry() tasks := taskbiz.NewTaskUsecaseWithRegistry(taskdata.NewTaskRepo(reportTaskHTTPData{provider}), registry) alerts := businessbiz.NewAlertTriggerUsecase(businessdata.NewAlertTriggerRepo(provider)) - worker.NewTaskMethods(tasks, nil, nil, nil, nil, alerts).RegisterTasks(registry) + worker.NewTaskMethods(tasks, nil, nil, nil, nil, alerts, nil).RegisterTasks(registry) scheduler := worker.NewTaskScheduler(tasks, nil, worker.NewTaskExecutorWithRegistry(tasks, registry), nil) defer func() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) diff --git a/cmd/analytics_http_test.go b/cmd/analytics_http_test.go index bc3e3fa..013489a 100644 --- a/cmd/analytics_http_test.go +++ b/cmd/analytics_http_test.go @@ -77,7 +77,7 @@ func TestChannelAndGameAnalyticsHTTP(t *testing.T) { } } provider := businessHTTPData{db} - uc := businessbiz.NewAnalyticsUsecase(businessdata.NewAnalyticsRepo(provider), businessbiz.NewAccountUsecase(businessdata.NewAccountRepo(provider)), businessbiz.NewUserPreferenceUsecase(businessdata.NewUserPreferenceRepo(provider))) + uc := businessbiz.NewAnalyticsUsecase(businessdata.NewAnalyticsRepo(provider), businessbiz.NewAccountUsecase(businessdata.NewAccountRepo(provider)), businessbiz.NewUserPreferenceUsecase(businessdata.NewUserPreferenceRepo(provider)), nil) h := handler.NewAnalytics(businessservice.NewAnalyticsService(uc)) engine := gin.New() engine.Use(func(c *gin.Context) { diff --git a/cmd/business_http_test.go b/cmd/business_http_test.go index 356a2c8..bd7c0b8 100644 --- a/cmd/business_http_test.go +++ b/cmd/business_http_test.go @@ -138,7 +138,7 @@ func TestBusinessHTTPListWithSystemOnlyAndCompleteDatabase(t *testing.T) { } return value } - created := call("POST", "/sw/business", `{"userName":"http_child","realName":"接口测试","password":"abc123"}`, "")["data"].(map[string]any) + created := call("POST", "/sw/business", `{"userName":"http_child","realName":"接口测试","password":"abc123","hireDate":"2026-11-01"}`, "")["data"].(map[string]any) channels := call("GET", "/sw/business/2/promotes", "", "")["data"].(map[string]any) if channels["total"] != float64(1) || channels["rows"].([]any)[0].(map[string]any)["promoteName"] != "测试渠道" { t.Fatalf("HTTP 旗下渠道范围错误: %+v", channels) diff --git a/cmd/business_management_http_test.go b/cmd/business_management_http_test.go new file mode 100644 index 0000000..7ebc998 --- /dev/null +++ b/cmd/business_management_http_test.go @@ -0,0 +1,168 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http/httptest" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "gorm.io/gorm" + biz "kra/internal/biz/business" + "kra/internal/biz/system" + data "kra/internal/data/business" + systemdata "kra/internal/data/system" + "kra/internal/server/handler" + "kra/internal/server/router" + service "kra/internal/service/business" +) + +func TestBusinessManagementHTTPGroupsGoalsAndEffectivenessAgree(t *testing.T) { + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "management.db")), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + pool, _ := db.DB() + t.Cleanup(func() { _ = pool.Close() }) + for _, step := range append(systemdata.Migrations(), data.Migrations()...) { + if err := step.Migrate(db); err != nil { + t.Fatal(err) + } + } + for _, sql := range []string{ + "INSERT INTO sys_users(id,uuid,username,nick_name,level,parent_id,id_path,enable) VALUES(1,'root','root','root',0,0,'0',1),(7,'owner','owner','甲组织',1,0,'0',1),(8,'branch','branch','甲分部',2,7,'0,7',1),(9,'worker','worker','甲成员',3,8,'0,7,8',1),(10,'other','other','外部组织',1,0,'0',1)", + "INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,platform_busier_account,status) VALUES(9,'lx',14,'sdk','0'),(10,'hw',20,'outside','0')", + "CREATE TABLE tab_promote(id INTEGER,source_db TEXT,account TEXT,nickname TEXT,parent_name TEXT,real_name TEXT,promote_level INTEGER,register_type INTEGER,status INTEGER,lock_status INTEGER,parent_id INTEGER,top_promote_id INTEGER,busier_id INTEGER,create_time INTEGER,last_login_time INTEGER)", + "CREATE TABLE tab_spend(id INTEGER,source_db TEXT,user_id INTEGER,promote_id INTEGER,pay_amount DECIMAL(20,2),pay_time INTEGER,pay_status INTEGER)", + "CREATE TABLE tab_user(id INTEGER,source_db TEXT,promote_id INTEGER,puid INTEGER,idcard TEXT)", + "CREATE TABLE tab_promote_apply(source_db TEXT,promote_id INTEGER,game_id INTEGER,status INTEGER)", + "CREATE TABLE tab_sw_report_day(source_db TEXT,promote_id INTEGER,game_id INTEGER,game_name TEXT,dedup_mode INTEGER,stat_date DATE,total_pay DECIMAL(20,2))", + "CREATE TABLE tab_sw_report_day_key(source_db TEXT,promote_id INTEGER,game_id INTEGER,game_name TEXT,dedup_mode INTEGER,stat_date DATE,metric INTEGER,identity_key TEXT)", + } { + if err := db.Exec(sql).Error; err != nil { + t.Fatal(err) + } + } + loc := time.FixedZone("Asia/Shanghai", 8*3600) + now := time.Now().In(loc) + month := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc) + previous := month.AddDate(0, 0, -1).Unix() + stamp := month.Unix() + 1 + if err := db.Exec("INSERT INTO tab_promote VALUES(100,'lx','channel','渠道甲','','',1,0,1,0,0,0,14,?,?),(101,'hw','outside','渠道乙','','',1,0,1,0,0,0,20,?,?),(102,'lx','new','新渠道','','',1,0,1,0,0,0,14,?,?)", previous, stamp, stamp, stamp, stamp, stamp).Error; err != nil { + t.Fatal(err) + } + if err := db.Exec("INSERT INTO tab_spend VALUES(1,'lx',1,100,3500,?,1),(2,'hw',1,101,99999,?,1),(3,'lx',1,102,99999,?,0)", stamp, stamp, stamp).Error; err != nil { + t.Fatal(err) + } + if err := db.Exec("INSERT INTO tab_sw_report_day VALUES('lx',100,1,'game',0,?,3500),('hw',101,1,'game',0,?,99999)", month.Format("2006-01-02"), month.Format("2006-01-02")).Error; err != nil { + t.Fatal(err) + } + provider := businessHTTPData{db} + accounts := biz.NewAccountUsecase(data.NewAccountRepo(provider)) + preferences := biz.NewUserPreferenceUsecase(data.NewUserPreferenceRepo(provider)) + settings := data.NewOtherSettingsRepo(provider) + effective := biz.NewEffectivenessUsecase(data.NewEffectivenessRepo(provider), settings) + teams := biz.NewTeamUsecase(data.NewTeamRepo(provider), accounts) + goalRepo := data.NewGoalRepo(provider) + performance := biz.NewPerformanceUsecase(data.NewPerformanceRepo(provider), teams, effective, goalRepo) + goals := biz.NewGoalUsecase(goalRepo, performance) + dashboard := biz.NewDashboardUsecase(data.NewDashboardRepo(provider), biz.NewWorkbenchUsecase(data.NewWorkbenchRepo(provider), accounts, preferences), effective) + engine := gin.New() + engine.Use(func(c *gin.Context) { + id, _ := strconv.ParseUint(c.GetHeader("X-Test-User"), 10, 64) + role := uint(901) + if id == 1 { + role = 888 + } else if id == 8 { + role = 902 + } else if id == 9 { + role = 903 + } + c.Request = c.Request.WithContext(system.NewActorContext(c.Request.Context(), system.Actor{UserID: uint(id), AuthorityID: role})) + c.Next() + }) + router.RegisterBusinessPerformance(engine.Group(""), handler.NewBusinessPerformance(service.NewBusinessPerformanceService(teams, performance, goals, dashboard))) + router.RegisterOtherSettings(engine.Group(""), handler.NewOtherSettings(service.NewOtherSettingsService(biz.NewOtherSettingsUsecase(settings, effective)))) + channels := biz.NewChannelUsecase(data.NewChannelRepo(provider), nil, accounts, preferences, nil, effective) + engine.GET("/sw/promote/list", handler.NewChannel(service.NewChannelService(channels)).List) + call := func(user int, method, path, body string, code int) map[string]any { + t.Helper() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Test-User", strconv.Itoa(user)) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + var value map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &value); err != nil { + t.Fatalf("%s: %s", path, rec.Body.String()) + } + if value["code"] != float64(code) { + t.Fatalf("%s: %+v", path, value) + } + result, _ := value["data"].(map[string]any) + return result + } + view := call(7, "GET", "/sw/groups", "", 0) + if len(view["members"].([]any)) != 3 { + t.Fatal("不得包含其他一级组织") + } + view = call(7, "PUT", "/sw/groups", `{"revision":0,"groups":[{"name":"甲组","memberIds":[8,9]}]}`, 0) + if len(view["groups"].([]any)) != 1 { + t.Fatal("小组保存失败") + } + call(8, "PUT", "/sw/groups", `{"revision":1,"groups":[]}`, 7) + call(7, "PUT", "/sw/goals", fmt.Sprintf(`{"kind":"person","targetId":9,"month":%q,"open":0,"effective":1,"amount":3000}`, month.Format("2006-01")), 0) + call(9, "PUT", "/sw/goals", fmt.Sprintf(`{"kind":"person","targetId":9,"month":%q,"open":100}`, month.Format("2006-01")), 7) + query := fmt.Sprintf("/sw/performance?year=%d&beginMonth=%d&endMonth=%d", now.Year(), now.Month(), now.Month()) + stats := call(7, "GET", query, "", 0) + total := stats["total"].(map[string]any) + if total["open"] != float64(1) || total["effective"] != float64(1) || total["pay"] != "3500.00" { + t.Fatalf("权限范围/开端与达标月份/流水错误: %+v", total) + } + groupID := uint64(view["groups"].([]any)[0].(map[string]any)["id"].(float64)) + details := call(7, "GET", fmt.Sprintf("/sw/performance/details?kind=team&targetId=%d&begin=%s&end=%s&metric=effective", groupID, month.Format("2006-01-02"), now.Format("2006-01-02")), "", 0) + if details["total"] != float64(2) || len(details["rows"].([]any)) != 2 { + t.Fatal("team contribution drilldown must agree with summed member performance") + } + details = call(7, "GET", fmt.Sprintf("/sw/performance/details?kind=group&targetId=%d&memberIds=9&begin=%s&end=%s&metric=effective", groupID, month.Format("2006-01-02"), now.Format("2006-01-02")), "", 0) + if details["total"] != float64(1) { + t.Fatal("filtered matrix subtotal must return unique channels") + } + filtered := call(7, "GET", query+"&groupId=999", "", 0) + if filtered["matrixTotal"].(map[string]any)["open"] != float64(0) || filtered["total"].(map[string]any)["open"] != float64(1) { + t.Fatal("matrix filter must not corrupt scope cards") + } + overview := call(7, "GET", fmt.Sprintf("/sw/workbench/overview?sourceDbList=lx&period=custom&begin=%s&end=%s", month.Format("2006-01-02"), now.Format("2006-01-02")), "", 0) + cards := overview["cards"].(map[string]any) + if len(cards) != 8 || cards["lockedPromote"].(map[string]any)["value"] != nil || cards["terminatedPromote"].(map[string]any)["compareLabel"] != "事件数据未接入" { + t.Fatal("event cards must be visible without fabricating historical counts") + } + mine := call(9, "GET", "/sw/goals/mine", "", 0) + if mine["actual"].(map[string]any)["pay"] != "3500.00" { + t.Fatal("首页目标必须与绩效同源") + } + listed := call(7, "GET", "/sw/promote/list?sourceDb=lx&effective=true", "", 0) + if listed["total"] != float64(1) || listed["rows"].([]any)[0].(map[string]any)["effective"] != true { + t.Fatal("渠道有效端筛选未接通") + } + call(7, "GET", fmt.Sprintf("/sw/performance/details?kind=person&targetId=10&begin=%s&end=%s&metric=effective", month.Format("2006-01-02"), now.Format("2006-01-02")), "", 7) + call(1, "PUT", "/sw/settings/effectiveness", `{"amount":4000,"days":30}`, 0) + stats = call(7, "GET", query, "", 0) + if stats["total"].(map[string]any)["effective"] != float64(0) { + t.Fatal("修改规则后绩效未重算") + } + listed = call(7, "GET", "/sw/promote/list?sourceDb=lx&effective=true", "", 0) + if listed["total"] != float64(0) { + t.Fatal("渠道列表仍然使用旧规则") + } + call(7, "PUT", "/sw/business/profile", `{"userId":9,"realName":"新负责人","phoneNumber":"13900000009","hireDate":"2025-01-02","employment":"probation"}`, 0) + call(7, "PUT", "/sw/goals", fmt.Sprintf(`{"kind":"person","targetId":9,"month":%q,"open":null,"effective":null,"amount":null}`, month.Format("2006-01")), 0) + if call(9, "GET", "/sw/goals/mine", "", 0) != nil { + t.Fatal("清空目标后首页应隐藏目标卡") + } +} diff --git a/cmd/channel_http_test.go b/cmd/channel_http_test.go index 128c672..498c2a2 100644 --- a/cmd/channel_http_test.go +++ b/cmd/channel_http_test.go @@ -75,7 +75,7 @@ func TestChannelHTTPListsMetricsOptionsAndAtomicCreation(t *testing.T) { } provider := businessHTTPData{db} passwords := &gameplatform.Passwords{AuthCode: "test-auth"} - uc := businessbiz.NewChannelUsecase(businessdata.NewChannelRepo(provider), businessdata.NewChannelWriteRepo(channelHTTPDatabases{platform}), businessbiz.NewAccountUsecase(businessdata.NewAccountRepo(provider)), businessbiz.NewUserPreferenceUsecase(businessdata.NewUserPreferenceRepo(provider)), passwords) + uc := businessbiz.NewChannelUsecase(businessdata.NewChannelRepo(provider), businessdata.NewChannelWriteRepo(channelHTTPDatabases{platform}), businessbiz.NewAccountUsecase(businessdata.NewAccountRepo(provider)), businessbiz.NewUserPreferenceUsecase(businessdata.NewUserPreferenceRepo(provider)), passwords, nil) engine := gin.New() engine.Use(func(c *gin.Context) { id, _ := strconv.ParseUint(c.GetHeader("X-Test-User"), 10, 64) diff --git a/cmd/other_settings_http_test.go b/cmd/other_settings_http_test.go index 83bafba..09fabd8 100644 --- a/cmd/other_settings_http_test.go +++ b/cmd/other_settings_http_test.go @@ -33,7 +33,7 @@ func TestOtherSettingsHTTPPersistenceAndAccess(t *testing.T) { t.Fatal(err) } } - uc := businessbiz.NewOtherSettingsUsecase(businessdata.NewOtherSettingsRepo(businessHTTPData{db})) + uc := businessbiz.NewOtherSettingsUsecase(businessdata.NewOtherSettingsRepo(businessHTTPData{db}), nil) engine := gin.New() engine.Use(func(c *gin.Context) { role, _ := strconv.ParseUint(c.GetHeader("X-Test-Role"), 10, 64) diff --git a/cmd/report_build_http_test.go b/cmd/report_build_http_test.go index f8e2f99..1085cd0 100644 --- a/cmd/report_build_http_test.go +++ b/cmd/report_build_http_test.go @@ -70,7 +70,7 @@ func TestReportBuildThroughNativeTaskHTTP(t *testing.T) { registry := platformtask.NewRegistry() tasks := taskbiz.NewTaskUsecaseWithRegistry(taskdata.NewTaskRepo(reportTaskHTTPData{provider}), registry) reports := businessbiz.NewReportBuildUsecase(businessdata.NewReportBuildRepo(provider)) - worker.NewTaskMethods(tasks, nil, nil, nil, reports, nil).RegisterTasks(registry) + worker.NewTaskMethods(tasks, nil, nil, nil, reports, nil, nil).RegisterTasks(registry) scheduler := worker.NewTaskScheduler(tasks, nil, worker.NewTaskExecutorWithRegistry(tasks, registry), nil) defer func() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -102,8 +102,8 @@ func TestReportBuildThroughNativeTaskHTTP(t *testing.T) { return value["data"].(map[string]any) } methods := call("GET", "getRegisteredMethods", nil)["methods"].([]any) - if len(methods) != 9 { - t.Fatalf("六个商务汇总方法未注册: %d", len(methods)) + if len(methods) != 11 { + t.Fatalf("原生任务方法数量不正确: %d", len(methods)) } for index, method := range []string{"swReportDayBackfillTask", "swReportServerDayBackfillTask"} { name := fmt.Sprintf("隔离汇总任务%d", index) diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 39f340a..d311e4f 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -82,20 +82,32 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg auditRecorderUsecase := system2.NewAuditRecorderUsecase(auditRecordRepo) auditRecorder := system3.NewAuditRecorder(auditRecorderUsecase) registry := taskRegistry() + teamRepo := business.NewTeamRepo(dataData) + accountRepo := business.NewAccountRepo(dataData) + accountUsecase := business2.NewAccountUsecase(accountRepo) + teamUsecase := business2.NewTeamUsecase(teamRepo, accountUsecase) + performanceRepo := business.NewPerformanceRepo(dataData) + effectivenessRepo := business.NewEffectivenessRepo(dataData) otherSettingsRepo := business.NewOtherSettingsRepo(dataData) - otherSettingsUsecase := business2.NewOtherSettingsUsecase(otherSettingsRepo) + effectivenessUsecase := business2.NewEffectivenessUsecase(effectivenessRepo, otherSettingsRepo) + goalRepo := business.NewGoalRepo(dataData) + performanceUsecase := business2.NewPerformanceUsecase(performanceRepo, teamUsecase, effectivenessUsecase, goalRepo) + goalUsecase := business2.NewGoalUsecase(goalRepo, performanceUsecase) + dashboardRepo := business.NewDashboardRepo(dataData) + workbenchRepo := business.NewWorkbenchRepo(dataData) + userPreferenceRepo := business.NewUserPreferenceRepo(dataData) + userPreferenceUsecase := business2.NewUserPreferenceUsecase(userPreferenceRepo) + workbenchUsecase := business2.NewWorkbenchUsecase(workbenchRepo, accountUsecase, userPreferenceUsecase) + dashboardUsecase := business2.NewDashboardUsecase(dashboardRepo, workbenchUsecase, effectivenessUsecase) + businessPerformanceService := business3.NewBusinessPerformanceService(teamUsecase, performanceUsecase, goalUsecase, dashboardUsecase) + businessPerformance := handler.NewBusinessPerformance(businessPerformanceService) + otherSettingsUsecase := business2.NewOtherSettingsUsecase(otherSettingsRepo, effectivenessUsecase) otherSettingsService := business3.NewOtherSettingsService(otherSettingsUsecase) otherSettings := handler.NewOtherSettings(otherSettingsService) rankingRepo := business.NewRankingRepo(dataData) - accountRepo := business.NewAccountRepo(dataData) - accountUsecase := business2.NewAccountUsecase(accountRepo) - userPreferenceRepo := business.NewUserPreferenceRepo(dataData) - userPreferenceUsecase := business2.NewUserPreferenceUsecase(userPreferenceRepo) rankingUsecase := business2.NewRankingUsecase(rankingRepo, accountUsecase, userPreferenceUsecase) rankingService := business3.NewRankingService(rankingUsecase) ranking := handler.NewRanking(rankingService) - workbenchRepo := business.NewWorkbenchRepo(dataData) - workbenchUsecase := business2.NewWorkbenchUsecase(workbenchRepo, accountUsecase, userPreferenceUsecase) workbenchService := business3.NewWorkbenchService(workbenchUsecase) workbench := handler.NewWorkbench(workbenchService) serverAnalysisRepo := business.NewServerAnalysisRepo(dataData) @@ -103,7 +115,7 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg serverAnalysisService := business3.NewServerAnalysisService(serverAnalysisUsecase) serverAnalysis := handler.NewServerAnalysis(serverAnalysisService) analyticsRepo := business.NewAnalyticsRepo(dataData) - analyticsUsecase := business2.NewAnalyticsUsecase(analyticsRepo, accountUsecase, userPreferenceUsecase) + analyticsUsecase := business2.NewAnalyticsUsecase(analyticsRepo, accountUsecase, userPreferenceUsecase, effectivenessUsecase) analyticsService := business3.NewAnalyticsService(analyticsUsecase) analytics := handler.NewAnalytics(analyticsService) ledgerRepo := business.NewLedgerRepo(dataData) @@ -121,7 +133,7 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg businessPortrait := handler.NewBusinessPortrait(businessPortraitService) channelWriteRepo := business.NewChannelWriteRepo(dataData) platformPasswordEncoder := gameplatform.NewPasswordEncoder() - channelUsecase := business2.NewChannelUsecase(channelRepo, channelWriteRepo, accountUsecase, userPreferenceUsecase, platformPasswordEncoder) + channelUsecase := business2.NewChannelUsecase(channelRepo, channelWriteRepo, accountUsecase, userPreferenceUsecase, platformPasswordEncoder, effectivenessUsecase) channelService := business3.NewChannelService(channelUsecase) channel := handler.NewChannel(channelService) activationRepo := business.NewActivationRepo(dataData, dataData) @@ -251,44 +263,45 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg integrationConfigService := integration2.NewIntegrationConfigService(integrationConfigUsecase) integrationConfig := handler.NewIntegrationConfig(integrationConfigService) set := &handler.Set{ - OtherSettings: otherSettings, - Ranking: ranking, - Workbench: workbench, - ServerAnalysis: serverAnalysis, - Analytics: analytics, - Ledger: ledger, - ChannelPortrait: channelPortrait, - BusinessPortrait: businessPortrait, - Channel: channel, - Activation: activation, - AlertRecord: alertRecord, - AlertRule: alertRule, - BusinessPromote: businessPromote, - Business: handlerBusiness, - BusinessPassword: businessPassword, - SMS: handlerSMS, - Authority: authority, - Menu: menu, - API: api, - Permission: permission, - Organization: organization, - Announcement: announcement, - Email: handlerEmail, - Payment: handlerPayment, - Task: handlerTask, - Media: media, - Audit: audit, - Export: export, - Version: version, - Dictionary: dictionary, - Parameter: parameter, - APIToken: apiToken, - SystemConfig: systemConfig, - Public: public, - User: user, - Navigation: navigation, - Session: session, - IntegrationConfig: integrationConfig, + BusinessPerformance: businessPerformance, + OtherSettings: otherSettings, + Ranking: ranking, + Workbench: workbench, + ServerAnalysis: serverAnalysis, + Analytics: analytics, + Ledger: ledger, + ChannelPortrait: channelPortrait, + BusinessPortrait: businessPortrait, + Channel: channel, + Activation: activation, + AlertRecord: alertRecord, + AlertRule: alertRule, + BusinessPromote: businessPromote, + Business: handlerBusiness, + BusinessPassword: businessPassword, + SMS: handlerSMS, + Authority: authority, + Menu: menu, + API: api, + Permission: permission, + Organization: organization, + Announcement: announcement, + Email: handlerEmail, + Payment: handlerPayment, + Task: handlerTask, + Media: media, + Audit: audit, + Export: export, + Version: version, + Dictionary: dictionary, + Parameter: parameter, + APIToken: apiToken, + SystemConfig: systemConfig, + Public: public, + User: user, + Navigation: navigation, + Session: session, + IntegrationConfig: integrationConfig, } routes := router.NewRoutes(set) maintenanceRepo := system.NewMaintenanceRepo(dataData) @@ -297,7 +310,7 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg reportBuildUsecase := business2.NewReportBuildUsecase(reportBuildRepo) alertTriggerRepo := business.NewAlertTriggerRepo(dataData) alertTriggerUsecase := business2.NewAlertTriggerUsecase(alertTriggerRepo) - taskMethods := worker.NewTaskMethods(taskUsecase, maintenanceUsecase, mediaUsecase, store, reportBuildUsecase, alertTriggerUsecase) + taskMethods := worker.NewTaskMethods(taskUsecase, maintenanceUsecase, mediaUsecase, store, reportBuildUsecase, alertTriggerUsecase, effectivenessUsecase) routeRegistrar := buildRuntime(registry, routes, taskMethods) websocketServer, cleanup2, err := websocket.New(runtimeconfigStore) if err != nil { diff --git a/internal/biz/biz.go b/internal/biz/biz.go index a4fa9d7..e225f69 100644 --- a/internal/biz/biz.go +++ b/internal/biz/biz.go @@ -11,4 +11,4 @@ import ( ) // ProviderSet is biz providers. -var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewOtherSettingsUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase, business.NewActivationUsecase, business.NewChannelUsecase, business.NewBusinessPortraitUsecase, business.NewChannelPortraitUsecase, business.NewLedgerUsecase, business.NewAnalyticsUsecase, business.NewServerAnalysisUsecase, business.NewWorkbenchUsecase, business.NewRankingUsecase, business.NewReportBuildUsecase, business.NewAlertTriggerUsecase) +var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewOtherSettingsUsecase, business.NewTeamUsecase, business.NewGoalUsecase, business.NewEffectivenessUsecase, business.NewPerformanceUsecase, business.NewDashboardUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase, business.NewActivationUsecase, business.NewChannelUsecase, business.NewBusinessPortraitUsecase, business.NewChannelPortraitUsecase, business.NewLedgerUsecase, business.NewAnalyticsUsecase, business.NewServerAnalysisUsecase, business.NewWorkbenchUsecase, business.NewRankingUsecase, business.NewReportBuildUsecase, business.NewAlertTriggerUsecase) diff --git a/internal/biz/business/account.go b/internal/biz/business/account.go index 0873a92..e862db3 100644 --- a/internal/biz/business/account.go +++ b/internal/biz/business/account.go @@ -23,6 +23,7 @@ var ( ) type Account struct { + HireDate, Employment string ID uint Username, NickName, Password, RealName, Phone, Email, QQNumber string Level int @@ -218,6 +219,9 @@ func (uc *AccountUsecase) Update(ctx context.Context, input *AccountUpdate) erro if account.AuthorityID == 888 { return ErrAccountScope } + if input.Username != nil && *input.Username != account.Username || input.NickName != nil && *input.NickName != account.NickName { + return ErrAccountInput + } // 编辑资料和审核不会改变商务层级、归属、角色或密码。 for _, field := range []struct { dst *string diff --git a/internal/biz/business/analytics.go b/internal/biz/business/analytics.go index f321f7a..a3ff5d8 100644 --- a/internal/biz/business/analytics.go +++ b/internal/biz/business/analytics.go @@ -55,31 +55,34 @@ func (r *AnalyticsRow) BusinessName() string { } type AnalyticsData struct { - Rows []*AnalyticsRow - GlobalCounts *PortraitCounts - GlobalPay string - Available bool + EffectiveCount int64 + Rows []*AnalyticsRow + GlobalCounts *PortraitCounts + GlobalPay string + Available bool } type AnalyticsPage struct { - Rows []*AnalyticsRow - Total int64 - Summary *AnalyticsRow - Begin, End string - Available bool + EffectiveCount int64 + Rows []*AnalyticsRow + Total int64 + Summary *AnalyticsRow + Begin, End string + Available bool } type AnalyticsRepo interface { Aggregate(context.Context, AnalyticsKind, AnalyticsScope, AnalyticsFilter) (*AnalyticsData, error) FillNames(context.Context, AnalyticsKind, AnalyticsScope, AnalyticsFilter, []*AnalyticsRow) error } type AnalyticsUsecase struct { - repo AnalyticsRepo - accounts *AccountUsecase - preferences *UserPreferenceUsecase - now func() time.Time + repo AnalyticsRepo + accounts *AccountUsecase + preferences *UserPreferenceUsecase + now func() time.Time + effectiveness *EffectivenessUsecase } -func NewAnalyticsUsecase(repo AnalyticsRepo, accounts *AccountUsecase, preferences *UserPreferenceUsecase) *AnalyticsUsecase { - return &AnalyticsUsecase{repo: repo, accounts: accounts, preferences: preferences, now: time.Now} +func NewAnalyticsUsecase(repo AnalyticsRepo, accounts *AccountUsecase, preferences *UserPreferenceUsecase, effectiveness *EffectivenessUsecase) *AnalyticsUsecase { + return &AnalyticsUsecase{repo: repo, accounts: accounts, preferences: preferences, now: time.Now, effectiveness: effectiveness} } func analyticsCap(c PortraitCounts) PortraitCounts { return PortraitCounts{Register: min(c.Register, 2147483647), Active: min(c.Active, 2147483647), Pay: min(c.Pay, 2147483647), NewPay: min(c.NewPay, 2147483647)} @@ -140,6 +143,11 @@ func (uc *AnalyticsUsecase) List(ctx context.Context, kind AnalyticsKind, filter filter.End = filter.EndDate.Format("2006-01-02") out := &AnalyticsPage{Rows: []*AnalyticsRow{}, Begin: filter.Begin, End: filter.End, Available: true} scope := AnalyticsScope{Viewer: viewer, IncludeSub: pref.IncludeSubPromote, IDCardDedup: pref.IDCardDedup} + if kind == ChannelAnalytics && uc.effectiveness != nil { + if err := uc.effectiveness.Ensure(ctx); err != nil { + return nil, err + } + } data, err := uc.repo.Aggregate(ctx, kind, scope, filter) if err != nil { return nil, err @@ -148,6 +156,7 @@ func (uc *AnalyticsUsecase) List(ctx context.Context, kind AnalyticsKind, filter out.Available = false return out, nil } + out.EffectiveCount = data.EffectiveCount counts := PortraitCounts{} pay := new(big.Rat) for _, row := range data.Rows { diff --git a/internal/biz/business/analytics_test.go b/internal/biz/business/analytics_test.go index ad3d6ef..c27411d 100644 --- a/internal/biz/business/analytics_test.go +++ b/internal/biz/business/analytics_test.go @@ -40,7 +40,7 @@ func (r *analyticsRepoFake) Aggregate(_ context.Context, _ AnalyticsKind, s Anal func analyticsFixture() (*AnalyticsUsecase, *analyticsRepoFake) { a := &accountRepoFake{accounts: map[uint]*Account{1: {ID: 1}, 7: {ID: 7, Level: 1, IDPath: "0"}}} r := &analyticsRepoFake{data: &AnalyticsData{Available: true, Rows: []*AnalyticsRow{{AnalyticsKey: AnalyticsKey{SourceDB: "lx", ID: 1}, Name: "甲", Counts: PortraitCounts{Register: 2, Active: 4, Pay: 2, NewPay: 1}, Pay: "20.00"}, {AnalyticsKey: AnalyticsKey{SourceDB: "hw", ID: 2}, Name: "乙", Counts: PortraitCounts{Register: 3, Active: 6, Pay: 1, NewPay: 1}, Pay: "30.00"}}, GlobalCounts: &PortraitCounts{Register: 4, Active: 8, Pay: 3}, GlobalPay: "45.00"}} - uc := NewAnalyticsUsecase(r, NewAccountUsecase(a), NewUserPreferenceUsecase(&channelPreferenceFake{UserPreference{IncludeSubPromote: true, IDCardDedup: true}})) + uc := NewAnalyticsUsecase(r, NewAccountUsecase(a), NewUserPreferenceUsecase(&channelPreferenceFake{UserPreference{IncludeSubPromote: true, IDCardDedup: true}}), nil) uc.now = func() time.Time { return time.Date(2026, 11, 4, 9, 0, 0, 0, portraitLocation) } return uc, r } diff --git a/internal/biz/business/channel.go b/internal/biz/business/channel.go index 6d9092d..83c2f68 100644 --- a/internal/biz/business/channel.go +++ b/internal/biz/business/channel.go @@ -41,6 +41,7 @@ type Channel struct { GameCount, PlayerCount *int64 TotalPayAmount *string CreatedUnix, LastLoginUnix *int64 + QualifiedAt *int64 } func (c *Channel) BusinessDisplayName() string { @@ -70,6 +71,7 @@ func (c *Channel) IncludesStat(other *Channel, includeSub bool) bool { } type ChannelFilter struct { + Effective *bool Sources []string BusierUserID *uint BusierAccount, Keyword string @@ -133,16 +135,17 @@ type ChannelWriteRepo interface { } type PlatformPasswordEncoder interface{ Encode(string) (string, error) } type ChannelUsecase struct { - repo ChannelRepo - writer ChannelWriteRepo - accounts *AccountUsecase - preferences *UserPreferenceUsecase - passwords PlatformPasswordEncoder - now func() time.Time + repo ChannelRepo + writer ChannelWriteRepo + accounts *AccountUsecase + preferences *UserPreferenceUsecase + passwords PlatformPasswordEncoder + effectiveness *EffectivenessUsecase + now func() time.Time } -func NewChannelUsecase(repo ChannelRepo, writer ChannelWriteRepo, accounts *AccountUsecase, preferences *UserPreferenceUsecase, passwords PlatformPasswordEncoder) *ChannelUsecase { - return &ChannelUsecase{repo: repo, writer: writer, accounts: accounts, preferences: preferences, passwords: passwords, now: time.Now} +func NewChannelUsecase(repo ChannelRepo, writer ChannelWriteRepo, accounts *AccountUsecase, preferences *UserPreferenceUsecase, passwords PlatformPasswordEncoder, effectiveness *EffectivenessUsecase) *ChannelUsecase { + return &ChannelUsecase{repo: repo, writer: writer, accounts: accounts, preferences: preferences, passwords: passwords, now: time.Now, effectiveness: effectiveness} } func (uc *ChannelUsecase) scope(ctx context.Context) (UserScope, error) { actor, a, err := uc.accounts.actor(ctx) @@ -170,6 +173,11 @@ func (uc *ChannelUsecase) List(ctx context.Context, filter ChannelFilter, option if err != nil { return nil, err } + if uc.effectiveness != nil { + if err := uc.effectiveness.Ensure(ctx); err != nil { + return nil, err + } + } page, err := uc.repo.List(ctx, scope, filter, options...) if err != nil || len(page.Rows) == 0 { return page, err diff --git a/internal/biz/business/channel_test.go b/internal/biz/business/channel_test.go index 393b93d..5b83b34 100644 --- a/internal/biz/business/channel_test.go +++ b/internal/biz/business/channel_test.go @@ -71,7 +71,7 @@ func channelFixture() (*ChannelUsecase, *channelRepoFake, *channelPreferenceFake p := &channelPreferenceFake{UserPreference{IncludeSubPromote: true, IDCardDedup: true}} w, e := &channelWriteFake{}, &channelEncoderFake{} a := &accountRepoFake{accounts: map[uint]*Account{1: {ID: 1}, 7: {ID: 7, Level: 1, IDPath: "0"}, 8: {ID: 8, Level: 2, IDPath: "0,7"}, 9: {ID: 9, Level: 3, IDPath: "0,7,8"}}} - uc := NewChannelUsecase(r, w, NewAccountUsecase(a), NewUserPreferenceUsecase(p), e) + uc := NewChannelUsecase(r, w, NewAccountUsecase(a), NewUserPreferenceUsecase(p), e, nil) uc.now = func() time.Time { return time.Date(2026, 11, 3, 12, 0, 0, 0, time.UTC) } return uc, r, p, w, e } diff --git a/internal/biz/business/dashboard.go b/internal/biz/business/dashboard.go new file mode 100644 index 0000000..eee587a --- /dev/null +++ b/internal/biz/business/dashboard.go @@ -0,0 +1,104 @@ +package business + +import ( + "context" + "strconv" + "time" +) + +type DashboardWindow struct{ Begin, End, PreviousBegin, PreviousEnd time.Time } +type DashboardMetrics struct { + Register, Active, Effective, ActiveChannels, NewChannels int64 + LockedChannels, TerminatedChannels int64 + Pay string +} +type DashboardData struct { + Current, Previous DashboardMetrics + Available bool + EventsAvailable bool +} +type DashboardRepo interface { + Overview(context.Context, AnalyticsScope, []string, DashboardWindow) (*DashboardData, error) +} +type DashboardOverview struct { + Cards map[string]*WorkbenchCard + Begin, End string + Settings OtherSettings + Available bool +} +type DashboardUsecase struct { + repo DashboardRepo + workbench *WorkbenchUsecase + effective *EffectivenessUsecase + now func() time.Time +} + +func NewDashboardUsecase(repo DashboardRepo, workbench *WorkbenchUsecase, effective *EffectivenessUsecase) *DashboardUsecase { + return &DashboardUsecase{repo: repo, workbench: workbench, effective: effective, now: time.Now} +} +func (uc *DashboardUsecase) Overview(ctx context.Context, sources []string, period string, begin, end *time.Time) (*DashboardOverview, error) { + scope, err := uc.workbench.scope(ctx, sources) + if err != nil { + return nil, err + } + today := workbenchWindow(uc.now()).Today + b, e := today, today + switch period { + case "", "today": + case "yesterday": + b, e = today.AddDate(0, 0, -1), today.AddDate(0, 0, -1) + case "last7": + b = today.AddDate(0, 0, -6) + case "last30": + b = today.AddDate(0, 0, -29) + case "custom": + if begin == nil || end == nil { + return nil, ErrPerformanceInput + } + b, e = *begin, *end + default: + return nil, ErrPerformanceInput + } + if e.Before(b) || e.After(today) { + return nil, ErrPerformanceInput + } + days := int(e.Sub(b).Hours()/24) + 1 + window := DashboardWindow{Begin: b, End: e, PreviousBegin: b.AddDate(0, 0, -days), PreviousEnd: b.AddDate(0, 0, -1)} + if err := uc.effective.Ensure(ctx); err != nil { + return nil, err + } + rule, err := uc.effective.Rule(ctx) + if err != nil { + return nil, err + } + data, err := uc.repo.Overview(ctx, scope, sources, window) + if err != nil { + return nil, err + } + r := &DashboardOverview{Cards: map[string]*WorkbenchCard{}, Begin: b.Format("2006-01-02"), End: e.Format("2006-01-02"), Settings: rule, Available: data.Available} + num := func(v int64) string { return strconv.FormatInt(v, 10) } + for _, card := range []struct{ key, current, previous string }{{"todayRegister", num(data.Current.Register), num(data.Previous.Register)}, {"todayActive", num(data.Current.Active), num(data.Previous.Active)}, {"todayPay", data.Current.Pay, data.Previous.Pay}, {"effectivePromote", num(data.Current.Effective), num(data.Previous.Effective)}, {"activePromote", num(data.Current.ActiveChannels), num(data.Previous.ActiveChannels)}, {"newPromote", num(data.Current.NewChannels), num(data.Previous.NewChannels)}} { + value, err := workbenchCard(card.current, card.previous, "较上一同期", data.Available) + if err != nil { + return nil, err + } + r.Cards[card.key] = value + } + // The imported SDK channel table has only current state; historical event + // counts must stay unavailable until a timestamped event source is connected. + for _, card := range []struct { + key string + current, previous int64 + }{{"lockedPromote", data.Current.LockedChannels, data.Previous.LockedChannels}, {"terminatedPromote", data.Current.TerminatedChannels, data.Previous.TerminatedChannels}} { + label := "较上一同期" + if !data.EventsAvailable { + label = "事件数据未接入" + } + value, err := workbenchCard(num(card.current), num(card.previous), label, data.Available && data.EventsAvailable) + if err != nil { + return nil, err + } + r.Cards[card.key] = value + } + return r, nil +} diff --git a/internal/biz/business/effectiveness.go b/internal/biz/business/effectiveness.go new file mode 100644 index 0000000..235b395 --- /dev/null +++ b/internal/biz/business/effectiveness.go @@ -0,0 +1,148 @@ +package business + +import ( + "context" + "crypto/sha256" + "fmt" + "math" + "math/big" + "strconv" + "time" + + "github.com/go-kratos/kratos/v3/errors" +) + +var ErrEffectivenessConflict = errors.Conflict("SW_EFFECTIVENESS_CONFLICT", "有效端规则或数据已更新,请重试") +var ErrEffectivenessUnavailable = errors.BadRequest("SW_EFFECTIVENESS_UNAVAILABLE", "有效端计算所需渠道或充值数据尚未接入") + +func EffectivenessRuleKey(rule OtherSettings) string { + return fmt.Sprintf("%x", sha256.Sum256([]byte(strconv.FormatInt(rule.Amount, 10)+":"+strconv.FormatInt(rule.Days, 10)))) +} + +type EffectivenessUsecase struct { + repo EffectivenessRepo + settings OtherSettingsRepo + gate chan struct{} + now func() time.Time +} + +func NewEffectivenessUsecase(repo EffectivenessRepo, settings OtherSettingsRepo) *EffectivenessUsecase { + gate := make(chan struct{}, 1) + gate <- struct{}{} + return &EffectivenessUsecase{repo: repo, settings: settings, gate: gate, now: time.Now} +} +func (uc *EffectivenessUsecase) Rule(ctx context.Context) (OtherSettings, error) { + v, err := uc.settings.Find(ctx) + if err != nil { + return OtherSettings{}, err + } + if v == nil { + return DefaultOtherSettings(), nil + } + return *v, nil +} +func (uc *EffectivenessUsecase) Ensure(ctx context.Context) error { + rule, err := uc.Rule(ctx) + if err != nil { + return err + } + state, err := uc.repo.State(ctx) + if err != nil { + return err + } + if state.RuleKey == EffectivenessRuleKey(rule) && !state.BuiltAt.IsZero() { + return nil + } + _, err = uc.rebuild(ctx, rule, false, false, true) + return err +} +func (uc *EffectivenessUsecase) Refresh(ctx context.Context, full bool) error { + rule, err := uc.Rule(ctx) + if err != nil { + return err + } + _, err = uc.rebuild(ctx, rule, false, full, false) + return err +} +func (uc *EffectivenessUsecase) Reconfigure(ctx context.Context, rule OtherSettings) (*OtherSettings, error) { + if err := requireSettingsAdmin(ctx); err != nil { + return nil, err + } + if err := ValidateOtherSettings(rule); err != nil { + return nil, err + } + return uc.rebuild(ctx, rule, true, true, false) +} +func (uc *EffectivenessUsecase) rebuild(ctx context.Context, rule OtherSettings, save, full, ensureOnly bool) (*OtherSettings, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-uc.gate: + } + defer func() { uc.gate <- struct{}{} }() + state, err := uc.repo.State(ctx) + if err != nil { + return nil, err + } + key := EffectivenessRuleKey(rule) + if ensureOnly && state.RuleKey == key && !state.BuiltAt.IsZero() { + return &rule, nil + } + full = full || state.RuleKey != key + duration := int64(math.MaxInt64) + if rule.Days <= math.MaxInt64/86400 { + duration = rule.Days * 86400 + } + now := uc.now().Unix() + candidates, err := uc.repo.Candidates(ctx, key, duration, now, full) + if err != nil { + return nil, err + } + type accumulator struct { + candidate *EffectivenessCandidate + sum *big.Rat + at *int64 + complete bool + } + values := map[ChannelKey]*accumulator{} + threshold := big.NewRat(rule.Amount, 1) + for _, c := range candidates { + a := &accumulator{candidate: c, sum: new(big.Rat), at: c.PreviousAt, complete: true} + if c.CreatedAt != nil && *c.CreatedAt > now { + a.complete = false + } + if c.CreatedAt != nil && *c.CreatedAt > 0 && *c.CreatedAt <= now { + a.complete = duration <= now-*c.CreatedAt + if rule.Amount == 0 { + at := *c.CreatedAt + a.at = &at + } + } + values[c.ChannelKey] = a + } + err = uc.repo.Charges(ctx, candidates, duration, now, full, func(charge EffectivenessCharge) error { + a := values[charge.ChannelKey] + if a == nil { + return nil + } + amount, err := portraitNumber(charge.Amount) + if err != nil { + return err + } + a.sum.Add(a.sum, amount) + if a.sum.Cmp(threshold) >= 0 && (a.at == nil || charge.PaidAt < *a.at) { + at := charge.PaidAt + a.at = &at + } + return nil + }) + if err != nil { + return nil, err + } + results := make([]EffectivenessResult, 0, len(values)) + for _, c := range candidates { + a := values[c.ChannelKey] + results = append(results, EffectivenessResult{ChannelKey: c.ChannelKey, QualifiedAt: a.at, WindowPay: portraitDecimal(a.sum, 2), Complete: a.complete}) + } + return uc.repo.Publish(ctx, state, rule, key, results, save) +} diff --git a/internal/biz/business/goal_metrics.go b/internal/biz/business/goal_metrics.go new file mode 100644 index 0000000..1500aa3 --- /dev/null +++ b/internal/biz/business/goal_metrics.go @@ -0,0 +1,89 @@ +package business + +import ( + "math/big" + "time" +) + +func GoalMetric(goal *PerformanceGoal, key string) *int64 { + if goal == nil { + return nil + } + switch key { + case "open": + return goal.Open + case "effective": + return goal.Effective + case "pay": + return goal.Amount + } + return nil +} +func GoalPeriodTarget(goal *PerformanceGoal, key, period, grain string) *string { + if len(period) < 7 { + return nil + } + value := GoalMetric(goal, key) + if value == nil { + return nil + } + target := big.NewRat(*value, 1) + if grain == "day" { + month, err := time.Parse("2006-01", period[:7]) + if err != nil { + return nil + } + target.Quo(target, big.NewRat(int64(month.AddDate(0, 1, -1).Day()), 1)) + } + return stringPointer(portraitDecimal(target, 2)) +} +func GoalCompletion(periods map[string]PerformanceMetrics, goals map[string]*PerformanceGoal, key string) *string { + actual, target := new(big.Rat), new(big.Rat) + months := map[string]bool{} + for period, value := range periods { + if len(period) < 7 { + continue + } + month := period[:7] + goal := GoalMetric(goals[month], key) + if goal == nil { + continue + } + if !months[month] { + target.Add(target, big.NewRat(*goal, 1)) + months[month] = true + } + switch key { + case "open": + actual.Add(actual, big.NewRat(value.Open, 1)) + case "effective": + actual.Add(actual, big.NewRat(value.Effective, 1)) + case "pay": + amount, err := portraitNumber(value.Pay) + if err == nil { + actual.Add(actual, amount) + } + } + } + if target.Sign() <= 0 { + return nil + } + return stringPointer(portraitDecimal(actual.Quo(actual, target).Mul(actual, big.NewRat(100, 1)), 1)) +} +func PerformanceChange(current, previous PerformanceMetrics, key string) *string { + var a, b *big.Rat + switch key { + case "open": + a, b = big.NewRat(current.Open, 1), big.NewRat(previous.Open, 1) + case "effective": + a, b = big.NewRat(current.Effective, 1), big.NewRat(previous.Effective, 1) + case "pay": + a, _ = portraitNumber(current.Pay) + b, _ = portraitNumber(previous.Pay) + } + if a == nil || b == nil || b.Sign() == 0 { + return nil + } + return stringPointer(portraitDecimal(a.Sub(a, b).Quo(a, b).Mul(a, big.NewRat(100, 1)), 1)) +} +func PerformanceRate(value PerformanceMetrics) *string { return performanceRate(value) } diff --git a/internal/biz/business/management_test.go b/internal/biz/business/management_test.go new file mode 100644 index 0000000..b3084ae --- /dev/null +++ b/internal/biz/business/management_test.go @@ -0,0 +1,355 @@ +package business + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +type teamRepoFake struct { + members []*BusinessMember + catalog TeamCatalog + profile MemberProfile +} + +func (r *teamRepoFake) Members(_ context.Context, s UserScope) ([]*BusinessMember, error) { + out := []*BusinessMember{} + for _, m := range r.members { + if s.All || m.ID == s.SelfID && s.IncludeSelf || m.ID == s.SelfID && s.OnlySelf || !s.OnlySelf && (m.ParentID == s.SelfID || m.IDPath == s.DescendantPath || strings.HasPrefix(m.IDPath, s.DescendantPath+",")) { + copy := *m + out = append(out, ©) + } + } + return out, nil +} +func (r *teamRepoFake) Catalog(context.Context) (*TeamCatalog, error) { return &r.catalog, nil } +func (r *teamRepoFake) SaveCatalog(_ context.Context, revision uint64, groups []*BusinessGroup) (*TeamCatalog, error) { + if revision != r.catalog.Revision { + return nil, ErrTeamConflict + } + for i, g := range groups { + if g.ID == 0 { + g.ID = uint64(i + 100) + } + } + r.catalog = TeamCatalog{Revision: revision + 1, Groups: groups} + return &r.catalog, nil +} +func (r *teamRepoFake) UpdateProfile(_ context.Context, p MemberProfile) error { + r.profile = p + return nil +} + +type managementGoalFake struct{ values []*PerformanceGoal } + +func (r *managementGoalFake) List(_ context.Context, begin, end string) ([]*PerformanceGoal, error) { + out := []*PerformanceGoal{} + for _, g := range r.values { + if g.Month >= begin && g.Month <= end { + out = append(out, g) + } + } + return out, nil +} +func (r *managementGoalFake) Save(_ context.Context, g *PerformanceGoal) error { + next := []*PerformanceGoal{} + for _, v := range r.values { + if v.Kind != g.Kind || v.TargetID != g.TargetID || v.Month != g.Month { + next = append(next, v) + } + } + if g.Open != nil || g.Effective != nil || g.Amount != nil { + next = append(next, g) + } + r.values = next + return nil +} + +type managementEffectFake struct { + state EffectivenessState + candidates []*EffectivenessCandidate + charges []EffectivenessCharge + published []EffectivenessResult + fail error + calls int +} + +func (r *managementEffectFake) State(context.Context) (*EffectivenessState, error) { + s := r.state + return &s, nil +} +func (r *managementEffectFake) Candidates(context.Context, string, int64, int64, bool) ([]*EffectivenessCandidate, error) { + return r.candidates, nil +} +func (r *managementEffectFake) Charges(_ context.Context, _ []*EffectivenessCandidate, _, _ int64, _ bool, consume func(EffectivenessCharge) error) error { + if r.fail != nil { + return r.fail + } + for _, c := range r.charges { + if err := consume(c); err != nil { + return err + } + } + return nil +} +func (r *managementEffectFake) Publish(_ context.Context, _ *EffectivenessState, rule OtherSettings, key string, rows []EffectivenessResult, _ bool) (*OtherSettings, error) { + r.published = rows + r.calls++ + r.state.RuleKey = key + r.state.Revision++ + return &rule, nil +} + +type managementPerformanceFake struct { + facts PerformanceFacts + members []*BusinessMember + calls, details int + selection PerformanceDetailSelection +} + +func (r *managementPerformanceFake) Facts(_ context.Context, s UserScope, ids []uint, _ []string, _, _ time.Time) (*PerformanceFacts, error) { + r.calls++ + allowed := map[uint]bool{} + for _, m := range r.members { + if s.All || m.ID == s.SelfID || !s.OnlySelf && (m.ParentID == s.SelfID || m.IDPath == s.DescendantPath || strings.HasPrefix(m.IDPath, s.DescendantPath+",")) { + allowed[m.ID] = true + } + } + if ids != nil { + limited := map[uint]bool{} + for _, id := range ids { + if allowed[id] { + limited[id] = true + } + } + allowed = limited + } + out := &PerformanceFacts{Available: true} + keys := map[ChannelKey]bool{} + for _, c := range r.facts.Channels { + for _, id := range c.OwnerIDs { + if allowed[id] { + out.Channels = append(out.Channels, c) + keys[c.ChannelKey] = true + break + } + } + } + for _, c := range r.facts.Cash { + if keys[c.ChannelKey] { + out.Cash = append(out.Cash, c) + } + } + return out, nil +} +func (r *managementPerformanceFake) Details(_ context.Context, _ UserScope, selection PerformanceDetailSelection, _ []string, _, _ time.Time, _ string, _, _ int) ([]*PerformanceChannel, int64, error) { + r.details++ + r.selection = selection + return nil, 0, nil +} + +func TestPerformanceRateSortKeepsUndefinedRatesLast(t *testing.T) { + _, uc, _, teams, facts := managementFixture() + teams.catalog.Groups[0].MemberIDs = []uint{7, 8, 9, 10} + old := *facts.facts.Channels[0] + old.ID, old.OwnerIDs = 999, []uint{7} + facts.facts.Channels = append(facts.facts.Channels, &old) + facts.facts.Channels[0].QualifiedAt = nil + for _, asc := range []bool{true, false} { + page, err := uc.Query(accountContext(7, 901), PerformanceFilter{Year: 2026, BeginMonth: 11, EndMonth: 11, SortField: "rate", Ascending: asc}) + if err != nil { + t.Fatal(err) + } + first, second := uint(10), uint(7) + if !asc { + first, second = second, first + } + if page.Rows[0].Member.ID != first || page.Rows[1].Member.ID != second || page.Rows[2].Rate != nil || page.Rows[3].Rate != nil { + t.Fatalf("defined rates must sort before zero-output members: %+v", page.Rows) + } + } +} + +func TestPerformanceMatrixTotalsFollowDisplayedMemberUnion(t *testing.T) { + _, uc, _, teams, _ := managementFixture() + teams.catalog.Groups[0].MemberIDs = []uint{8, 9, 10} + ctx := accountContext(7, 901) + f := PerformanceFilter{Year: 2026, BeginMonth: 10, EndMonth: 11, Name: "leaf"} + page, err := uc.Query(ctx, f) + if err != nil { + t.Fatal(err) + } + if len(page.Rows) != 1 || page.MatrixTotal.Open != 1 || page.MatrixTotal.Effective != 1 || page.MatrixTotal.Pay != "9007199254740993.03" || page.Groups[0].UniqueTotal != page.MatrixTotal { + t.Fatalf("filtered member union/subtotal mismatch: %+v", page) + } + if page.Total.Open != 2 || page.Groups[0].Total.Open != 3 { + t.Fatal("scope cards and team contributions must retain their own scope") + } + f.Name, f.GroupID = "", func() *uint64 { id := uint64(999); return &id }() + page, err = uc.Query(ctx, f) + if err != nil || len(page.Rows) != 0 || page.MatrixTotal.Open != 0 || page.MatrixTotal.Pay != "0.00" || page.Total.Open != 2 { + t.Fatalf("empty matrix must have zero totals without changing scope cards: %+v %v", page, err) + } +} + +func TestPerformanceDetailSelectionPreservesContributionsAndScope(t *testing.T) { + _, uc, _, _, repo := managementFixture() + ctx, day := accountContext(7, 901), time.Date(2026, 11, 1, 0, 0, 0, 0, portraitLocation) + if _, _, err := uc.Details(ctx, "team", 1, day, day, "effective", 1, 20); err != nil { + t.Fatal(err) + } + if len(repo.selection.ContributorIDs) != 2 || len(repo.selection.OwnerIDs) != 2 { + t.Fatal("team drilldown must retain both contributing members") + } + if _, _, err := uc.Details(ctx, "group", 1, day, day, "effective", 1, 20, 9); err != nil { + t.Fatal(err) + } + if len(repo.selection.ContributorIDs) != 0 || len(repo.selection.OwnerIDs) != 1 || repo.selection.OwnerIDs[0] != 9 { + t.Fatal("matrix subtotal must use the selected member union") + } + calls := repo.details + if _, _, err := uc.Details(ctx, "group", 1, day, day, "effective", 1, 20, 99); err != ErrTeamScope || repo.details != calls { + t.Fatal("selected member IDs cannot escape the visible group") + } +} +func managementFixture() (*TeamUsecase, *PerformanceUsecase, *GoalUsecase, *teamRepoFake, *managementPerformanceFake) { + accounts := &accountRepoFake{accounts: map[uint]*Account{1: {ID: 1}, 7: {ID: 7, Username: "parent", NickName: "甲团队", Level: 1, IDPath: "0"}, 8: {ID: 8, Username: "branch", NickName: "甲分部", Level: 2, ParentID: 7, IDPath: "0,7"}, 9: {ID: 9, Username: "leaf", NickName: "甲成员", Level: 3, ParentID: 8, IDPath: "0,7,8"}, 10: {ID: 10, Username: "sibling", NickName: "乙成员", Level: 3, ParentID: 7, IDPath: "0,7"}, 99: {ID: 99, Username: "outside", NickName: "外部", Level: 1, IDPath: "0"}}} + repo := &teamRepoFake{catalog: TeamCatalog{Groups: []*BusinessGroup{{ID: 1, Name: "重叠组", OwnerID: 1, MemberIDs: []uint{8, 9}}, {ID: 2, Name: "外部组", OwnerID: 99, Sort: 5, MemberIDs: []uint{99}}}}} + for _, id := range []uint{7, 8, 9, 10, 99} { + repo.members = append(repo.members, &BusinessMember{Account: *accounts.accounts[id]}) + } + teams := NewTeamUsecase(repo, NewAccountUsecase(accounts)) + rule := DefaultOtherSettings() + effective := NewEffectivenessUsecase(&managementEffectFake{state: EffectivenessState{RuleKey: EffectivenessRuleKey(rule), BuiltAt: time.Now()}}, &otherSettingsFake{value: &rule}) + stamp := func(value string) *int64 { + date, _ := time.ParseInLocation("2006-01-02", value, portraitLocation) + n := date.Unix() + return &n + } + facts := &managementPerformanceFake{members: repo.members, facts: PerformanceFacts{Available: true, Channels: []*PerformanceChannel{{ChannelKey: ChannelKey{SourceDB: "lx", ID: 1}, OwnerIDs: []uint{9}, CreatedAt: stamp("2026-10-31"), QualifiedAt: stamp("2026-11-01")}, {ChannelKey: ChannelKey{SourceDB: "hw", ID: 1}, OwnerIDs: []uint{10}, CreatedAt: stamp("2026-11-02"), QualifiedAt: stamp("2026-11-03")}, {ChannelKey: ChannelKey{SourceDB: "yq", ID: 1}, OwnerIDs: []uint{99}, CreatedAt: stamp("2026-11-02")}}, Cash: []PerformanceCash{{ChannelKey: ChannelKey{SourceDB: "lx", ID: 1}, Date: "2026-10-31", Pay: "9007199254740993.01"}, {ChannelKey: ChannelKey{SourceDB: "lx", ID: 1}, Date: "2026-11-01", Pay: "0.02"}, {ChannelKey: ChannelKey{SourceDB: "hw", ID: 1}, Date: "2026-11-02", Pay: "10.00"}}}} + goalRepo := &managementGoalFake{} + performance := NewPerformanceUsecase(facts, teams, effective, goalRepo) + performance.now = func() time.Time { return time.Date(2026, 11, 4, 12, 0, 0, 0, portraitLocation) } + goals := NewGoalUsecase(goalRepo, performance) + goals.now = performance.now + return teams, performance, goals, repo, facts +} +func TestTeamScopeSingleMembershipAndPreservesOtherOrganizations(t *testing.T) { + teams, _, _, repo, _ := managementFixture() + ctx := accountContext(7, 901) + view, err := teams.View(ctx) + if err != nil || len(view.Members) != 4 || len(view.Groups) != 1 || !view.Groups[0].Overlapping { + t.Fatalf("范围或平级提示错误: %+v %v", view, err) + } + if _, err := teams.Save(accountContext(8, 902), 0, nil); err != ErrTeamScope { + t.Fatal("二级商务不能管理分组") + } + if _, err := teams.Save(ctx, 0, []*BusinessGroup{{Name: "新组", MemberIDs: []uint{99}}}); err != ErrTeamScope { + t.Fatal("不能把其他组织成员加入分组") + } + if _, err := teams.Save(ctx, 0, []*BusinessGroup{{Name: "甲", MemberIDs: []uint{9}}, {Name: "乙", MemberIDs: []uint{9}}}); err != ErrTeamInput { + t.Fatal("一人不能在两组") + } + if _, err := teams.Save(ctx, 0, []*BusinessGroup{{Name: "新组", MemberIDs: []uint{8, 9}}}); err != nil { + t.Fatal(err) + } + if len(repo.catalog.Groups) != 2 || repo.catalog.Groups[1].ID != 2 || repo.catalog.Groups[1].Sort != 5 { + t.Fatal("修改不得删除或重排不可见组织的小组") + } + if _, err := teams.Save(ctx, 0, nil); err != ErrTeamConflict { + t.Fatal("旧版本覆盖必须被拒绝") + } +} +func TestPerformanceDeduplicatesScopeAndMatrixButTeamSumsMembers(t *testing.T) { + _, uc, _, _, _ := managementFixture() + ctx := accountContext(7, 901) + f := PerformanceFilter{Year: 2026, BeginMonth: 10, EndMonth: 11} + page, err := uc.Query(ctx, f) + if err != nil { + t.Fatal(err) + } + if page.Total.Open != 2 || page.Total.Effective != 2 || page.Total.Pay != "9007199254741003.03" || page.ProductiveMembers != 2 { + t.Fatalf("跨平台、金额精度或去重合计错误: %+v", page.Total) + } + if len(page.Groups) != 1 || page.Groups[0].UniqueTotal.Open != 1 || page.Groups[0].Total.Open != 2 { + t.Fatal("矩阵小计应去重,团队绩效应按成员相加并提示重叠") + } + f.Name = "leaf" + filtered, err := uc.Query(ctx, f) + if err != nil || len(filtered.Rows) != 1 || filtered.Total != page.Total { + t.Fatal("姓名只能筛显示行,不能改变范围内合计") + } + f.Name = "" + f.BeginMonth = 11 + daily, err := uc.Query(ctx, f) + if err != nil || daily.Granularity != "day" || len(daily.Periods) != 4 || daily.Total.Open != 1 || daily.Total.Effective != 2 || daily.Rate == nil || *daily.Rate != "200.0" { + t.Fatalf("日粒度、未来日期或跨月达标错误: %+v %v", daily, err) + } + for _, row := range daily.Rows { + sum := PerformanceMetrics{Pay: "0.00"} + for _, m := range row.Periods { + sum = addPerformance(sum, m) + } + if sum != row.Total { + t.Fatal("逐日与区间合计不一致") + } + } +} +func TestGoalScopeHistoryAndNullableTargets(t *testing.T) { + _, perf, uc, _, repo := managementFixture() + zero := int64(0) + target := int64(10) + for _, ctx := range []context.Context{accountContext(8, 902), accountContext(9, 903)} { + if err := uc.Save(ctx, &PerformanceGoal{Kind: "person", TargetID: 9, Month: "2026-11", Open: &target}); err != ErrTeamScope { + t.Fatal("仅一级商务和超管能设目标") + } + } + if err := uc.Save(accountContext(7, 901), &PerformanceGoal{Kind: "person", TargetID: 7, Month: "2026-11", Open: &target}); err != ErrTeamScope { + t.Fatal("一级商务不能给自己设目标") + } + for _, month := range []string{"2026-05", "2027-01", "invalid"} { + if err := uc.Save(accountContext(7, 901), &PerformanceGoal{Kind: "person", TargetID: 9, Month: month, Open: &target}); err != ErrGoalInput { + t.Fatal("目标编辑区间错误") + } + } + if err := uc.Save(accountContext(7, 901), &PerformanceGoal{Kind: "person", TargetID: 9, Month: "2026-11", Open: &zero, Effective: &target}); err != nil { + t.Fatal(err) + } + page, err := uc.History(accountContext(9, 903), "person", 9) + if err != nil || page.CanEdit || len(page.Months) != 7 || repo.calls > 2 { + t.Fatalf("本人只读、历史范围或N+1错误: %+v %v", page, err) + } + for _, m := range page.Months { + if m.Month == "2026-11" && (m.Goal == nil || m.Goal.Open == nil || *m.Goal.Open != 0 || m.Goal.Amount != nil || m.Status != "ongoing") { + t.Fatal("零与未考核不能混淆") + } + } + if _, _, err := perf.Details(accountContext(9, 903), "person", 8, time.Now(), time.Now(), "open", 1, 20); err == nil || repo.details != 0 { + t.Fatal("下钻越权必须在访问数据前拒绝") + } +} +func TestEffectivenessFirstHitStickyZeroAndFailure(t *testing.T) { + created := int64(100) + previous := int64(120) + future := int64(2000) + r := &managementEffectFake{candidates: []*EffectivenessCandidate{{ChannelKey: ChannelKey{SourceDB: "lx", ID: 1}, CreatedAt: &created}, {ChannelKey: ChannelKey{SourceDB: "hw", ID: 1}, CreatedAt: &created, PreviousAt: &previous}, {ChannelKey: ChannelKey{SourceDB: "lx", ID: 2}, CreatedAt: &future}}, charges: []EffectivenessCharge{{ChannelKey: ChannelKey{SourceDB: "lx", ID: 1}, PaidAt: 130, Amount: "70.00"}, {ChannelKey: ChannelKey{SourceDB: "lx", ID: 1}, PaidAt: 140, Amount: "40.00"}, {ChannelKey: ChannelKey{SourceDB: "lx", ID: 1}, PaidAt: 150, Amount: "-60.00"}}} + uc := NewEffectivenessUsecase(r, &otherSettingsFake{}) + uc.now = func() time.Time { return time.Unix(1000, 0) } + if _, err := uc.Reconfigure(accountContext(1, 888), OtherSettings{Amount: 100, Days: 30}); err != nil { + t.Fatal(err) + } + if *r.published[0].QualifiedAt != 140 || r.published[0].WindowPay != "50.00" || *r.published[1].QualifiedAt != 120 || r.published[2].Complete { + t.Fatal("首次达标、保持标记或未来日期处理错误") + } + r.charges = nil + if _, err := uc.Reconfigure(accountContext(1, 888), OtherSettings{Amount: 0, Days: 1}); err != nil || *r.published[0].QualifiedAt != 100 || r.published[2].QualifiedAt != nil { + t.Fatal("零门槛仅已注册渠道即时达标") + } + r.fail = errors.New("read failed") + calls := r.calls + if _, err := uc.Reconfigure(accountContext(1, 888), OtherSettings{Amount: 1, Days: 1}); err == nil || r.calls != calls { + t.Fatal("重算失败不能发布部分结果或修改规则") + } +} diff --git a/internal/biz/business/other_settings.go b/internal/biz/business/other_settings.go index 764398a..39049cc 100644 --- a/internal/biz/business/other_settings.go +++ b/internal/biz/business/other_settings.go @@ -35,10 +35,13 @@ type OtherSettingsRepo interface { Save(context.Context, OtherSettings) (*OtherSettings, error) } -type OtherSettingsUsecase struct{ repo OtherSettingsRepo } +type OtherSettingsUsecase struct { + repo OtherSettingsRepo + effectiveness *EffectivenessUsecase +} -func NewOtherSettingsUsecase(repo OtherSettingsRepo) *OtherSettingsUsecase { - return &OtherSettingsUsecase{repo: repo} +func NewOtherSettingsUsecase(repo OtherSettingsRepo, effectiveness *EffectivenessUsecase) *OtherSettingsUsecase { + return &OtherSettingsUsecase{repo: repo, effectiveness: effectiveness} } func requireSettingsAdmin(ctx context.Context) error { @@ -74,5 +77,8 @@ func (uc *OtherSettingsUsecase) Save(ctx context.Context, value OtherSettings) ( if err := ValidateOtherSettings(value); err != nil { return nil, err } + if uc.effectiveness != nil { + return uc.effectiveness.Reconfigure(ctx, value) + } return uc.repo.Save(ctx, value) } diff --git a/internal/biz/business/other_settings_test.go b/internal/biz/business/other_settings_test.go index 4370de9..506f6d9 100644 --- a/internal/biz/business/other_settings_test.go +++ b/internal/biz/business/other_settings_test.go @@ -22,7 +22,7 @@ func (r *otherSettingsFake) Save(_ context.Context, value OtherSettings) (*Other func TestOtherSettingsOnlySuperAdminAndDefaults(t *testing.T) { r := &otherSettingsFake{} - uc := NewOtherSettingsUsecase(r) + uc := NewOtherSettingsUsecase(r, nil) for _, ctx := range []context.Context{context.Background(), accountContext(7, 901), accountContext(8, 902), accountContext(9, 903)} { if _, err := uc.Get(ctx); err == nil { t.Fatal("非超管不能读取设置") @@ -48,7 +48,7 @@ func TestOtherSettingsOnlySuperAdminAndDefaults(t *testing.T) { } func TestOtherSettingsRejectInvalidRange(t *testing.T) { - uc := NewOtherSettingsUsecase(&otherSettingsFake{}) + uc := NewOtherSettingsUsecase(&otherSettingsFake{}, nil) for _, value := range []OtherSettings{{Amount: -1, Days: 30}, {Amount: 3000, Days: 0}, {Amount: MaxSettingsInteger + 1, Days: 30}, {Amount: 3000, Days: MaxSettingsInteger + 1}} { if _, err := uc.Save(accountContext(1, 888), value); err != ErrOtherSettingsInput { t.Fatalf("不应保存非法值: %+v %v", value, err) diff --git a/internal/biz/business/performance.go b/internal/biz/business/performance.go new file mode 100644 index 0000000..edf6288 --- /dev/null +++ b/internal/biz/business/performance.go @@ -0,0 +1,528 @@ +package business + +import ( + "context" + "math/big" + "sort" + "strings" + "time" + + "github.com/go-kratos/kratos/v3/errors" +) + +var ErrPerformanceInput = errors.BadRequest("SW_PERFORMANCE_INPUT", "绩效统计区间或查询对象不正确") + +type PerformanceUsecase struct { + repo PerformanceRepo + teams *TeamUsecase + effective *EffectivenessUsecase + goals GoalRepo + now func() time.Time +} + +func NewPerformanceUsecase(repo PerformanceRepo, teams *TeamUsecase, effective *EffectivenessUsecase, goals GoalRepo) *PerformanceUsecase { + return &PerformanceUsecase{repo: repo, teams: teams, effective: effective, goals: goals, now: time.Now} +} + +type performanceAccumulator struct { + open, effective int64 + pay *big.Rat +} + +func newPerformanceAccumulator() *performanceAccumulator { + return &performanceAccumulator{pay: new(big.Rat)} +} +func (a *performanceAccumulator) metrics() PerformanceMetrics { + return PerformanceMetrics{Open: a.open, Effective: a.effective, Pay: portraitDecimal(a.pay, 2)} +} +func performanceRate(m PerformanceMetrics) *string { + if m.Open == 0 { + return nil + } + return stringPointer(portraitDecimal(new(big.Rat).Mul(big.NewRat(m.Effective, m.Open), big.NewRat(100, 1)), 1)) +} +func addPerformance(a, b PerformanceMetrics) PerformanceMetrics { + x, _ := portraitNumber(a.Pay) + y, _ := portraitNumber(b.Pay) + if x == nil { + x = new(big.Rat) + } + if y == nil { + y = new(big.Rat) + } + return PerformanceMetrics{Open: a.Open + b.Open, Effective: a.Effective + b.Effective, Pay: portraitDecimal(x.Add(x, y), 2)} +} + +type performanceBuckets struct { + total *performanceAccumulator + periods map[string]*performanceAccumulator +} + +func newPerformanceBuckets(periods []PerformancePeriod) *performanceBuckets { + b := &performanceBuckets{total: newPerformanceAccumulator(), periods: map[string]*performanceAccumulator{}} + for _, p := range periods { + b.periods[p.Key] = newPerformanceAccumulator() + } + return b +} +func (b *performanceBuckets) add(key, metric string, pay *big.Rat) { + a := b.periods[key] + if a == nil { + return + } + switch metric { + case "open": + a.open++ + b.total.open++ + case "effective": + a.effective++ + b.total.effective++ + case "pay": + a.pay.Add(a.pay, pay) + b.total.pay.Add(b.total.pay, pay) + } +} +func (b *performanceBuckets) values() map[string]PerformanceMetrics { + m := map[string]PerformanceMetrics{} + for key, value := range b.periods { + m[key] = value.metrics() + } + return m +} + +func performanceWindow(now time.Time, f PerformanceFilter) (time.Time, time.Time, []PerformancePeriod, string, error) { + now = now.In(portraitLocation) + if f.ForManagement { + begin, end := DefaultPerformanceRange(now) + periods := []PerformancePeriod{} + for m := begin; !m.After(end); m = m.AddDate(0, 1, 0) { + last := m.AddDate(0, 1, -1) + periods = append(periods, PerformancePeriod{Key: m.Format("2006-01"), Label: m.Format("1月"), Begin: m.Format("2006-01-02"), End: last.Format("2006-01-02")}) + } + return begin, end, periods, "month", nil + } + if f.Year == 0 { + f.Year = now.Year() + } + if f.BeginMonth == 0 { + f.BeginMonth = int(now.Month()) + } + if f.EndMonth == 0 { + f.EndMonth = f.BeginMonth + } + if f.Year < 1970 || f.Year > now.Year()+1 || f.BeginMonth < 1 || f.EndMonth > 12 || f.EndMonth < f.BeginMonth { + return time.Time{}, time.Time{}, nil, "", ErrPerformanceInput + } + begin := time.Date(f.Year, time.Month(f.BeginMonth), 1, 0, 0, 0, 0, portraitLocation) + end := time.Date(f.Year, time.Month(f.EndMonth)+1, 0, 0, 0, 0, 0, portraitLocation) + granularity := "month" + periods := []PerformancePeriod{} + if f.BeginMonth == f.EndMonth { + granularity = "day" + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, portraitLocation) + for day := begin; !day.After(end) && !day.After(today); day = day.AddDate(0, 0, 1) { + date := day.Format("2006-01-02") + periods = append(periods, PerformancePeriod{Key: date, Label: day.Format("1月2日"), Begin: date, End: date}) + } + } else { + for month := begin; !month.After(end); month = month.AddDate(0, 1, 0) { + last := month.AddDate(0, 1, -1) + periods = append(periods, PerformancePeriod{Key: month.Format("2006-01"), Label: month.Format("1月"), Begin: month.Format("2006-01-02"), End: last.Format("2006-01-02")}) + } + } + return begin, end, periods, granularity, nil +} +func (uc *PerformanceUsecase) Query(ctx context.Context, f PerformanceFilter) (*PerformancePage, error) { + return uc.query(ctx, f, nil) +} +func (uc *PerformanceUsecase) query(ctx context.Context, f PerformanceFilter, ownerIDs []uint) (*PerformancePage, error) { + view, err := uc.teams.View(ctx) + if err != nil { + return nil, err + } + scope := view.Scope + snapshotTime := uc.now().In(portraitLocation) + begin, end, periods, grain, err := performanceWindow(snapshotTime, f) + if err != nil { + return nil, err + } + if !begin.After(end) { + if err := uc.effective.Ensure(ctx); err != nil { + return nil, err + } + } + settings, err := uc.effective.Rule(ctx) + if err != nil { + return nil, err + } + priorBegin := begin.AddDate(0, -1, 0) + priorKey := priorBegin.Format("2006-01") + if grain == "day" { + priorBegin = begin.AddDate(0, 0, -1) + priorKey = priorBegin.Format("2006-01-02") + } + facts := &PerformanceFacts{Available: true} + if !begin.After(end) { + facts, err = uc.repo.Facts(ctx, scope, ownerIDs, nil, priorBegin, end) + } + if err != nil { + return nil, err + } + out := &PerformancePage{Rows: []*PerformanceRow{}, Groups: []*PerformanceGroupRow{}, Periods: periods, Trend: map[string]PerformanceMetrics{}, Begin: begin.Format("2006-01-02"), End: end.Format("2006-01-02"), Granularity: grain, Settings: settings, Available: facts.Available} + if !facts.Available { + return out, nil + } + all := newPerformanceBuckets(periods) + matrix := newPerformanceBuckets(periods) + needle := strings.ToLower(strings.TrimSpace(f.Name)) + visible := func(m *BusinessMember) bool { + return (f.Level == nil || m.Level == *f.Level) && (needle == "" || strings.Contains(strings.ToLower(memberName(m)+" "+m.Username+" "+m.RealName), needle)) + } + matrixVisible := func(m *BusinessMember) bool { + return visible(m) && (f.GroupID == nil || m.GroupID == *f.GroupID) + } + prior := newPerformanceAccumulator() + memberBuckets := map[uint]*performanceBuckets{} + groupBuckets := map[uint64]*performanceBuckets{} + byID := map[uint]*BusinessMember{} + families := map[uint][]uint{} + for _, member := range view.Members { + byID[member.ID] = member + memberBuckets[member.ID] = newPerformanceBuckets(periods) + } + descendants := map[uint]int{} + for _, owner := range view.Members { + families[owner.ID] = memberAncestors(owner, byID) + for _, ancestor := range families[owner.ID] { + if ancestor != owner.ID { + descendants[ancestor]++ + } + } + } + groupsByMember := map[uint][]uint64{} + for _, group := range view.Groups { + groupBuckets[group.ID] = newPerformanceBuckets(periods) + for _, id := range group.MemberIDs { + if matrixVisible(byID[id]) { + groupsByMember[id] = append(groupsByMember[id], group.ID) + } + } + } + type attribution struct { + members []uint + groups []uint64 + inMatrix bool + } + owners := map[ChannelKey]attribution{} + forOwners := func(ownerIDs []uint) attribution { + members, groups := map[uint]bool{}, map[uint64]bool{} + for _, owner := range ownerIDs { + for _, id := range families[owner] { + members[id] = true + for _, g := range groupsByMember[id] { + groups[g] = true + } + } + } + a := attribution{} + for id := range members { + a.members = append(a.members, id) + a.inMatrix = a.inMatrix || matrixVisible(byID[id]) + } + for id := range groups { + a.groups = append(a.groups, id) + } + return a + } + productive := map[uint]bool{} + periodKey := func(date string) string { + if grain == "month" && len(date) >= 7 { + return date[:7] + } + return date + } + dateKey := func(value *int64) string { + if value == nil || *value <= 0 || *value > snapshotTime.Unix() { + return "" + } + return periodKey(time.Unix(*value, 0).In(portraitLocation).Format("2006-01-02")) + } + add := func(a attribution, key, metric string, pay *big.Rat) { + if key == priorKey { + switch metric { + case "open": + prior.open++ + case "effective": + prior.effective++ + case "pay": + prior.pay.Add(prior.pay, pay) + } + } + if all.periods[key] == nil { + return + } + all.add(key, metric, pay) + if a.inMatrix { + matrix.add(key, metric, pay) + } + for _, id := range a.members { + memberBuckets[id].add(key, metric, pay) + } + for _, id := range a.groups { + groupBuckets[id].add(key, metric, pay) + } + } + for _, channel := range facts.Channels { + a := forOwners(channel.OwnerIDs) + owners[channel.ChannelKey] = a + open, effective := dateKey(channel.CreatedAt), dateKey(channel.QualifiedAt) + add(a, open, "open", nil) + add(a, effective, "effective", nil) + if all.periods[open] != nil || all.periods[effective] != nil { + for _, owner := range channel.OwnerIDs { + if byID[owner] != nil { + productive[owner] = true + } + } + } + } + ownerAttributions := map[ChannelKey]attribution{} + for _, cash := range facts.Cash { + if cash.Date > snapshotTime.Format("2006-01-02") { + continue + } + a, ok := owners[cash.ChannelKey] + if cash.OwnerIDs != nil { + a, ok = ownerAttributions[cash.ChannelKey] + if !ok { + a = forOwners(cash.OwnerIDs) + ownerAttributions[cash.ChannelKey] = a + } + ok = true + } + if !ok { + continue + } + pay, err := portraitNumber(cash.Pay) + if err != nil { + return nil, err + } + add(a, periodKey(cash.Date), "pay", pay) + } + goals, err := uc.goals.List(ctx, begin.Format("2006-01"), end.Format("2006-01")) + if err != nil { + return nil, err + } + personGoals, groupGoals := map[uint64]map[string]*PerformanceGoal{}, map[uint64]map[string]*PerformanceGoal{} + for _, goal := range goals { + target := personGoals + if goal.Kind == "group" { + target = groupGoals + } + if target[goal.TargetID] == nil { + target[goal.TargetID] = map[string]*PerformanceGoal{} + } + target[goal.TargetID][goal.Month] = goal + } + rows := map[uint]*PerformanceRow{} + for _, member := range view.Members { + b := memberBuckets[member.ID] + total := b.total.metrics() + row := &PerformanceRow{Member: member, Total: total, Rate: performanceRate(total), Periods: b.values(), Goals: personGoals[uint64(member.ID)]} + row.DescendantCount = descendants[member.ID] + rows[member.ID] = row + if matrixVisible(member) { + out.Rows = append(out.Rows, row) + } + } + for _, group := range view.Groups { + if len(group.MemberIDs) == 0 { + continue + } + b := groupBuckets[group.ID] + g := &PerformanceGroupRow{Group: group, Total: PerformanceMetrics{Pay: "0.00"}, UniqueTotal: b.total.metrics(), UniquePeriods: b.values(), Periods: map[string]PerformanceMetrics{}, Members: []*PerformanceRow{}, Goals: groupGoals[group.ID]} + for _, period := range periods { + g.Periods[period.Key] = PerformanceMetrics{Pay: "0.00"} + } + match := false + for _, id := range group.MemberIDs { + if row := rows[id]; row != nil { + g.Members = append(g.Members, row) + g.Total = addPerformance(g.Total, row.Total) + for key, value := range row.Periods { + g.Periods[key] = addPerformance(g.Periods[key], value) + } + if visible(row.Member) { + match = true + } + } + } + if match { + out.Groups = append(out.Groups, g) + } + } + out.Total = all.total.metrics() + out.MatrixTotal = matrix.total.metrics() + out.MatrixRate = performanceRate(out.MatrixTotal) + out.MatrixTrend = matrix.values() + out.Prior = prior.metrics() + out.Rate = performanceRate(out.Total) + out.Trend = all.values() + out.ProductiveMembers = int64(len(productive)) + groupOrder := map[uint64]int{} + for i, g := range view.Groups { + groupOrder[g.ID] = i + 1 + } + sort.SliceStable(out.Rows, func(i, j int) bool { + a, b := out.Rows[i], out.Rows[j] + ga, gb := a.Member.GroupID, b.Member.GroupID + if ga != gb { + if ga == 0 { + return false + } + if gb == 0 { + return true + } + return groupOrder[ga] < groupOrder[gb] + } + comparison := int64(0) + switch f.SortField { + case "open": + comparison = a.Total.Open - b.Total.Open + case "effective": + comparison = a.Total.Effective - b.Total.Effective + case "rate": + // Undefined rates always follow defined rates in either direction. + if a.Total.Open == 0 || b.Total.Open == 0 { + return a.Total.Open != 0 && b.Total.Open == 0 + } + comparison = int64(big.NewRat(a.Total.Effective, a.Total.Open).Cmp(big.NewRat(b.Total.Effective, b.Total.Open))) + } + if comparison == 0 { + return false + } + if f.Ascending { + return comparison < 0 + } + return comparison > 0 + }) + return out, nil +} + +func (uc *PerformanceUsecase) Target(ctx context.Context, kind string, id uint64) (UserScope, []uint, string, bool, error) { + view, err := uc.teams.View(ctx) + if err != nil { + return UserScope{}, nil, "", false, err + } + selection, name, editable, err := performanceTarget(view, kind, id, nil) + return view.Scope, selection.OwnerIDs, name, editable, err +} + +func performanceTarget(view *TeamView, kind string, id uint64, selected []uint) (PerformanceDetailSelection, string, bool, error) { + scope := view.Scope + rootIDs := []uint{} + name := "" + editable := false + if kind == "person" { + for _, m := range view.Members { + if uint64(m.ID) == id { + rootIDs = append(rootIDs, m.ID) + name = memberName(m) + editable = m.CanSetGoal + } + } + } else if kind == "group" { + for _, g := range view.Groups { + if g.ID == id { + rootIDs = append(rootIDs, g.MemberIDs...) + name = g.Name + editable = g.CanSetGoal + } + } + } else if kind == "scope" && id == 0 { + if scope.All { + return PerformanceDetailSelection{}, "可见范围", false, nil + } + for _, m := range view.Members { + rootIDs = append(rootIDs, m.ID) + } + name = "可见范围" + } else { + return PerformanceDetailSelection{}, "", false, ErrPerformanceInput + } + if len(rootIDs) == 0 { + return PerformanceDetailSelection{}, "", false, ErrTeamScope + } + set := map[uint]bool{} + byID := map[uint]*BusinessMember{} + for _, m := range view.Members { + byID[m.ID] = m + } + roots := map[uint]bool{} + for _, root := range rootIDs { + roots[root] = true + } + if len(selected) > 0 { + if kind != "group" { + return PerformanceDetailSelection{}, "", false, ErrPerformanceInput + } + limited := map[uint]bool{} + for _, root := range selected { + if !roots[root] { + return PerformanceDetailSelection{}, "", false, ErrTeamScope + } + limited[root] = true + } + roots = limited + rootIDs = nil + for root := range roots { + rootIDs = append(rootIDs, root) + } + } + for _, m := range view.Members { + for _, ancestor := range memberAncestors(m, byID) { + if roots[ancestor] { + set[m.ID] = true + break + } + } + } + ids := make([]uint, 0, len(set)) + for id := range set { + ids = append(ids, id) + } + return PerformanceDetailSelection{OwnerIDs: ids, ContributorIDs: rootIDs}, name, editable, nil +} +func (uc *PerformanceUsecase) Details(ctx context.Context, kind string, id uint64, begin, end time.Time, metric string, page, size int, members ...uint) ([]*PerformanceChannel, int64, error) { + view, err := uc.teams.View(ctx) + if err != nil { + return nil, 0, err + } + targetKind := kind + if kind == "team" { + targetKind = "group" + } + selection, _, _, err := performanceTarget(view, targetKind, id, members) + if err != nil { + return nil, 0, err + } + if kind != "team" { + selection.ContributorIDs = nil + } + if end.Before(begin) || !(metric == "open" || metric == "effective" || metric == "pay" || metric == "all") { + return nil, 0, ErrPerformanceInput + } + if page < 1 { + page = 1 + } + if size < 1 || size > 500 { + size = 20 + } + if page > int(^uint(0)>>1)/size { + return nil, 0, ErrPerformanceInput + } + if err := uc.effective.Ensure(ctx); err != nil { + return nil, 0, err + } + return uc.repo.Details(ctx, view.Scope, selection, nil, begin, end, metric, (page-1)*size, size) +} diff --git a/internal/biz/business/performance_goal.go b/internal/biz/business/performance_goal.go new file mode 100644 index 0000000..adeb19c --- /dev/null +++ b/internal/biz/business/performance_goal.go @@ -0,0 +1,220 @@ +package business + +import ( + "context" + "strconv" + "time" + + "github.com/go-kratos/kratos/v3/errors" +) + +var ErrGoalInput = errors.BadRequest("SW_GOAL_INPUT", "目标须为非负整数或留空,可修改近6个月及下月目标") + +type GoalUsecase struct { + repo GoalRepo + performance *PerformanceUsecase + now func() time.Time +} + +func (uc *GoalUsecase) Mine(ctx context.Context) (*GoalMonth, error) { + actor, _, err := uc.performance.teams.accounts.actor(ctx) + if err != nil { + return nil, err + } + now := uc.now().In(portraitLocation) + month := now.Format("2006-01") + goals, err := uc.repo.List(ctx, month, month) + if err != nil { + return nil, err + } + var goal *PerformanceGoal + for _, g := range goals { + if g.Kind == "person" && g.TargetID == uint64(actor.UserID) { + goal = g + break + } + } + if goal == nil { + return nil, nil + } + _, owners, _, _, err := uc.performance.Target(ctx, "person", uint64(actor.UserID)) + if err != nil { + return nil, err + } + data, err := uc.performance.query(ctx, PerformanceFilter{Year: now.Year(), BeginMonth: int(now.Month()), EndMonth: int(now.Month())}, owners) + if err != nil { + return nil, err + } + if !data.Available { + return nil, ErrWorkbenchUnavailable + } + for _, row := range data.Rows { + if row.Member.ID == actor.UserID { + return &GoalMonth{Month: month, Goal: goal, Actual: row.Total, Available: true}, nil + } + } + return nil, nil +} + +func NewGoalUsecase(repo GoalRepo, performance *PerformanceUsecase) *GoalUsecase { + return &GoalUsecase{repo: repo, performance: performance, now: time.Now} +} +func goalEditable(month string, now time.Time) bool { + current := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, portraitLocation) + return month >= current.AddDate(0, -5, 0).Format("2006-01") && month <= current.AddDate(0, 1, 0).Format("2006-01") +} +func (uc *GoalUsecase) Save(ctx context.Context, goal *PerformanceGoal) error { + if goal == nil { + return ErrGoalInput + } + _, _, _, editable, err := uc.performance.Target(ctx, goal.Kind, goal.TargetID) + if err != nil { + return err + } + if !editable { + return ErrTeamScope + } + month, err := time.ParseInLocation("2006-01", goal.Month, portraitLocation) + if err != nil || month.Format("2006-01") != goal.Month || !goalEditable(goal.Month, uc.now().In(portraitLocation)) { + return ErrGoalInput + } + for _, value := range []*int64{goal.Open, goal.Effective, goal.Amount} { + if value != nil && (*value < 0 || *value > MaxSettingsInteger) { + return ErrGoalInput + } + } + actor, _, err := uc.performance.teams.accounts.actor(ctx) + if err != nil { + return err + } + goal.UpdatedBy = actor.UserID + return uc.repo.Save(ctx, goal) +} +func goalStatus(goal *PerformanceGoal, actual PerformanceMetrics, month, current string) (string, int) { + if goal == nil || goal.Open == nil && goal.Effective == nil && goal.Amount == nil { + return "unset", 0 + } + missing := 0 + if goal.Open != nil && actual.Open < *goal.Open { + missing++ + } + if goal.Effective != nil && actual.Effective < *goal.Effective { + missing++ + } + if goal.Amount != nil { + pay, _ := portraitNumber(actual.Pay) + threshold, _ := portraitNumber(strconv.FormatInt(*goal.Amount, 10)) + if pay.Cmp(threshold) < 0 { + missing++ + } + } + if month == current { + return "ongoing", missing + } + if month > current { + return "future", missing + } + if missing == 0 { + return "achieved", 0 + } + return "missing", missing +} +func (uc *GoalUsecase) History(ctx context.Context, kind string, id uint64) (*GoalPage, error) { + _, owners, name, editable, err := uc.performance.Target(ctx, kind, id) + if err != nil { + return nil, err + } + now := uc.now().In(portraitLocation) + current := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, portraitLocation) + begin := current.AddDate(0, -5, 0) + end := current.AddDate(0, 1, 0) + goals, err := uc.repo.List(ctx, begin.Format("2006-01"), end.Format("2006-01")) + if err != nil { + return nil, err + } + byMonth := map[string]*PerformanceGoal{} + for _, g := range goals { + if g.Kind == kind && g.TargetID == id { + byMonth[g.Month] = g + } + } + out := &GoalPage{Kind: kind, TargetID: id, Name: name, CanEdit: editable, Months: []GoalMonth{}, MemberGoals: map[string]PerformanceMetrics{}} + if kind == "group" { + view, err := uc.performance.teams.View(ctx) + if err != nil { + return nil, err + } + members := map[uint64]bool{} + for _, group := range view.Groups { + if group.ID == id { + for _, member := range group.MemberIDs { + members[uint64(member)] = true + } + } + } + for _, goal := range goals { + if goal.Kind != "person" || !members[goal.TargetID] { + continue + } + value := PerformanceMetrics{Pay: "0.00"} + if goal.Open != nil { + value.Open = *goal.Open + } + if goal.Effective != nil { + value.Effective = *goal.Effective + } + if goal.Amount != nil { + value.Pay = strconv.FormatInt(*goal.Amount, 10) + } + out.MemberGoals[goal.Month] = addPerformance(out.MemberGoals[goal.Month], value) + } + } + // At most two year queries supply the complete history; no query per row. + actuals := map[string]PerformanceMetrics{} + available := true + for year := begin.Year(); year <= end.Year(); year++ { + from, to := 1, 12 + if year == begin.Year() { + from = int(begin.Month()) + } + if year == end.Year() { + to = int(end.Month()) + } + data, err := uc.performance.query(ctx, PerformanceFilter{Year: year, BeginMonth: from, EndMonth: to}, owners) + if err != nil { + return nil, err + } + available = available && data.Available + var values map[string]PerformanceMetrics + if kind == "person" { + for _, row := range data.Rows { + if uint64(row.Member.ID) == id { + values = row.Periods + } + } + } else { + for _, group := range data.Groups { + if group.Group.ID == id { + values = group.Periods + } + } + } + for key, value := range values { + month := key[:7] + actuals[month] = addPerformance(actuals[month], value) + } + } + for month := end; !month.Before(begin); month = month.AddDate(0, -1, 0) { + key := month.Format("2006-01") + actual := actuals[key] + if actual.Pay == "" { + actual.Pay = "0.00" + } + status, missing := goalStatus(byMonth[key], actual, key, current.Format("2006-01")) + if !available { + status = "unavailable" + } + out.Months = append(out.Months, GoalMonth{Month: key, Goal: byMonth[key], Actual: actual, Status: status, Missing: missing, CanEdit: editable && goalEditable(key, now), Available: available}) + } + return out, nil +} diff --git a/internal/biz/business/performance_models.go b/internal/biz/business/performance_models.go new file mode 100644 index 0000000..10f9227 --- /dev/null +++ b/internal/biz/business/performance_models.go @@ -0,0 +1,139 @@ +package business + +import ( + "context" + "time" +) + +type PerformanceMetrics struct { + Open, Effective int64 + Pay string +} +type PerformancePeriod struct{ Key, Label, Begin, End string } +type PerformanceFilter struct { + Year, BeginMonth, EndMonth int + Name string + Level *int + GroupID *uint64 + SortField string + Ascending bool + ForManagement bool +} +type PerformanceRow struct { + Member *BusinessMember + Total PerformanceMetrics + Rate *string + Periods map[string]PerformanceMetrics + Goals map[string]*PerformanceGoal + DescendantCount int +} +type PerformanceGroupRow struct { + Group *BusinessGroup + Total, UniqueTotal PerformanceMetrics + Periods, UniquePeriods map[string]PerformanceMetrics + Members []*PerformanceRow + Goals map[string]*PerformanceGoal +} +type PerformancePage struct { + MatrixTotal PerformanceMetrics + MatrixRate *string + MatrixTrend map[string]PerformanceMetrics + Prior PerformanceMetrics + Rows []*PerformanceRow + Groups []*PerformanceGroupRow + Total PerformanceMetrics + Rate *string + ProductiveMembers int64 + Periods []PerformancePeriod + Trend map[string]PerformanceMetrics + Begin, End, Granularity string + Settings OtherSettings + Available bool +} +type PerformanceChannel struct { + ChannelKey + ContributorID uint + ContributorName string + OwnerID uint + OwnerIDs []uint + Account, Name, OwnerName string + CreatedAt, QualifiedAt *int64 + WindowPay, PeriodPay string +} +type PerformanceCash struct { + ChannelKey + OwnerIDs []uint + Date, Pay string +} +type PerformanceFacts struct { + Channels []*PerformanceChannel + Cash []PerformanceCash + Available bool +} +type PerformanceRepo interface { + Facts(context.Context, UserScope, []uint, []string, time.Time, time.Time) (*PerformanceFacts, error) + Details(context.Context, UserScope, PerformanceDetailSelection, []string, time.Time, time.Time, string, int, int) ([]*PerformanceChannel, int64, error) +} + +// ContributorIDs requests one row per contributing member and channel. +// Without contributors the same channels are returned as a deduplicated union. +type PerformanceDetailSelection struct { + OwnerIDs []uint + ContributorIDs []uint +} +type PerformanceGoal struct { + Kind string + TargetID uint64 + Month string + Open, Effective, Amount *int64 + UpdatedBy uint + UpdatedAt time.Time +} +type GoalRepo interface { + List(context.Context, string, string) ([]*PerformanceGoal, error) + Save(context.Context, *PerformanceGoal) error +} +type GoalMonth struct { + Available bool + Month string + Goal *PerformanceGoal + Actual PerformanceMetrics + Status string + Missing int + CanEdit bool +} +type GoalPage struct { + Kind, Name string + TargetID uint64 + CanEdit bool + Months []GoalMonth + MemberGoals map[string]PerformanceMetrics +} + +type EffectivenessCandidate struct { + ChannelKey + CreatedAt, PreviousAt *int64 + PreviousPay string +} +type EffectivenessCharge struct { + ChannelKey + PaidAt int64 + Amount string +} +type EffectivenessResult struct { + ChannelKey + QualifiedAt *int64 + WindowPay string + Complete bool +} +type EffectivenessState struct { + RuleKey string + Revision uint64 + BuiltAt time.Time +} +type EffectivenessRepo interface { + State(context.Context) (*EffectivenessState, error) + Candidates(context.Context, string, int64, int64, bool) ([]*EffectivenessCandidate, error) + Charges(context.Context, []*EffectivenessCandidate, int64, int64, bool, func(EffectivenessCharge) error) error + Publish(context.Context, *EffectivenessState, OtherSettings, string, []EffectivenessResult, bool) (*OtherSettings, error) +} diff --git a/internal/biz/business/team.go b/internal/biz/business/team.go new file mode 100644 index 0000000..237e77b --- /dev/null +++ b/internal/biz/business/team.go @@ -0,0 +1,351 @@ +package business + +import ( + "context" + "sort" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/go-kratos/kratos/v3/errors" + "golang.org/x/crypto/bcrypt" +) + +var ( + ErrTeamInput = errors.BadRequest("SW_TEAM_INPUT", "小组名称须为1至10个字,每位商务只能加入一个小组") + ErrTeamScope = errors.Forbidden("SW_TEAM_SCOPE", "无权管理该小组或成员") + ErrTeamConflict = errors.Conflict("SW_TEAM_CONFLICT", "小组配置已被其他人修改,请刷新后重试") + ErrMemberInput = errors.BadRequest("SW_MEMBER_INPUT", "请填写有效的姓名、联系电话、入职日期和在职状态") +) + +type BusinessMember struct { + Account + GroupID uint64 + GroupName string + CanEdit, CanSetGoal bool +} + +type BusinessGroup struct { + ID uint64 + Name string + OwnerID uint + Sort int + MemberIDs []uint + CanEdit, CanSetGoal, Mine, Partial, Overlapping bool + Relation string + ChannelCount int64 +} + +type TeamCatalog struct { + Revision uint64 + Groups []*BusinessGroup +} +type TeamView struct { + Scope UserScope + Revision uint64 + Groups []*BusinessGroup + Members []*BusinessMember + CanManage bool + ViewerID uint +} +type MemberProfile struct { + ID uint + RealName, Phone, HireDate, Employment, Password string +} +type TeamRepo interface { + Members(context.Context, UserScope) ([]*BusinessMember, error) + Catalog(context.Context) (*TeamCatalog, error) + SaveCatalog(context.Context, uint64, []*BusinessGroup) (*TeamCatalog, error) + UpdateProfile(context.Context, MemberProfile) error +} +type TeamUsecase struct { + repo TeamRepo + accounts *AccountUsecase +} + +func NewTeamUsecase(repo TeamRepo, accounts *AccountUsecase) *TeamUsecase { + return &TeamUsecase{repo: repo, accounts: accounts} +} + +func memberName(member *BusinessMember) string { + if strings.TrimSpace(member.NickName) != "" { + return member.NickName + } + return member.Username +} + +// Business depth is bounded; derive ancestors from IDs instead of comparing +// every member with every other member on each statistics request. +func memberAncestors(member *BusinessMember, byID map[uint]*BusinessMember) []uint { + ids := []uint{member.ID} + seen := map[uint]bool{member.ID: true} + add := func(id uint) { + if !seen[id] && byID[id] != nil && descendantOf(&byID[id].Account, &member.Account) { + seen[id] = true + ids = append(ids, id) + } + } + add(member.ParentID) + for _, part := range strings.Split(member.IDPath, ",") { + id, err := strconv.ParseUint(part, 10, 64) + if err == nil { + add(uint(id)) + } + } + return ids +} + +func organizeMembers(members []*BusinessMember) { + byID := map[uint]*BusinessMember{} + children := map[uint][]*BusinessMember{} + for _, m := range members { + byID[m.ID] = m + children[m.ParentID] = append(children[m.ParentID], m) + } + for _, list := range children { + sort.SliceStable(list, func(i, j int) bool { return list[i].ID < list[j].ID }) + } + roots := []*BusinessMember{} + for _, m := range members { + if byID[m.ParentID] == nil { + roots = append(roots, m) + } + } + sort.SliceStable(roots, func(i, j int) bool { return roots[i].ID < roots[j].ID }) + ordered := make([]*BusinessMember, 0, len(members)) + seen := map[uint]bool{} + var visit func(*BusinessMember) + visit = func(m *BusinessMember) { + if seen[m.ID] { + return + } + seen[m.ID] = true + ordered = append(ordered, m) + for _, c := range children[m.ID] { + visit(c) + } + } + for _, m := range roots { + visit(m) + } + for _, m := range members { + visit(m) + } + copy(members, ordered) +} + +func (uc *TeamUsecase) View(ctx context.Context) (*TeamView, error) { + actor, account, err := uc.accounts.actor(ctx) + if err != nil { + return nil, err + } + scope := portraitUserScope(account) + scope.All = actor.AuthorityID == 888 + members, err := uc.repo.Members(ctx, scope) + if err != nil { + return nil, err + } + organizeMembers(members) + catalog, err := uc.repo.Catalog(ctx) + if err != nil { + return nil, err + } + view := &TeamView{Revision: catalog.Revision, Groups: []*BusinessGroup{}, Members: members, CanManage: scope.All || account.Level == 1, ViewerID: account.ID, Scope: scope} + visible := map[uint]*BusinessMember{} + order := map[uint]int{} + for i, m := range members { + m.GroupID, m.GroupName = 0, "" + visible[m.ID] = m + order[m.ID] = i + m.CanEdit = scope.All || m.ID == account.ID || descendantOf(account, &m.Account) + m.CanSetGoal = scope.All || (account.Level == 1 && descendantOf(account, &m.Account)) + } + for _, g := range catalog.Groups { + group := *g + group.MemberIDs = []uint{} + for _, id := range g.MemberIDs { + if m := visible[id]; m != nil { + group.MemberIDs = append(group.MemberIDs, id) + m.GroupID = g.ID + m.GroupName = g.Name + if id == account.ID { + group.Mine = true + } + if m.PromoteCount != nil { + group.ChannelCount += *m.PromoteCount + } + } + } + if len(group.MemberIDs) == 0 && !scope.All && visible[g.OwnerID] == nil { + continue + } + group.Partial = len(group.MemberIDs) != len(g.MemberIDs) + group.CanEdit = scope.All || view.CanManage && !group.Partial + group.CanSetGoal = view.CanManage && len(group.MemberIDs) > 0 + if group.Mine { + group.Relation = "本组" + } else if len(group.MemberIDs) > 0 { + nearest := visible[group.MemberIDs[0]] + for _, id := range group.MemberIDs { + m := visible[id] + if m.Level < nearest.Level || (m.Level == nearest.Level && order[m.ID] < order[nearest.ID]) { + nearest = m + } + } + group.Relation = "下级 · " + memberName(nearest) + } + memberIDs := map[uint]bool{} + for _, id := range group.MemberIDs { + memberIDs[id] = true + } + for _, id := range group.MemberIDs { + for _, ancestor := range memberAncestors(visible[id], visible) { + if ancestor != id && memberIDs[ancestor] { + group.Overlapping = true + } + } + } + view.Groups = append(view.Groups, &group) + } + return view, nil +} + +func (uc *TeamUsecase) Save(ctx context.Context, revision uint64, input []*BusinessGroup) (*TeamView, error) { + view, err := uc.View(ctx) + if err != nil { + return nil, err + } + if !view.CanManage { + return nil, ErrTeamScope + } + if revision != view.Revision { + return nil, ErrTeamConflict + } + all, err := uc.repo.Catalog(ctx) + if err != nil { + return nil, err + } + if all.Revision != revision { + return nil, ErrTeamConflict + } + maxSort := 0 + for _, g := range all.Groups { + if g.Sort >= maxSort { + maxSort = g.Sort + 1 + } + } + visible := map[uint]bool{} + editable := map[uint64]*BusinessGroup{} + previous := map[uint64]*BusinessGroup{} + for _, m := range view.Members { + visible[m.ID] = true + } + for _, g := range view.Groups { + editable[g.ID] = g + } + for _, g := range all.Groups { + previous[g.ID] = g + } + if len(input) > 500 { + return nil, ErrTeamInput + } + seenGroups := map[uint64]bool{} + seenMembers := map[uint]bool{} + next := []*BusinessGroup{} + for index, g := range input { + if g == nil { + return nil, ErrTeamInput + } + name := strings.TrimSpace(g.Name) + if utf8.RuneCountInString(name) < 1 || utf8.RuneCountInString(name) > 10 { + return nil, ErrTeamInput + } + copy := &BusinessGroup{ID: g.ID, Name: name, OwnerID: view.ViewerID, Sort: index, MemberIDs: []uint{}} + if !view.Scope.All { + copy.Sort = maxSort + maxSort++ + } + if g.ID != 0 { + old := previous[g.ID] + permission := editable[g.ID] + if old == nil || permission == nil || seenGroups[g.ID] { + return nil, ErrTeamScope + } + if !permission.CanEdit && name != old.Name { + return nil, ErrTeamScope + } + seenGroups[g.ID] = true + copy.OwnerID = old.OwnerID + if !view.Scope.All { + copy.Sort = old.Sort + } + for _, id := range old.MemberIDs { + if !visible[id] { + copy.MemberIDs = append(copy.MemberIDs, id) + } + } + } + for _, id := range g.MemberIDs { + if !visible[id] { + return nil, ErrTeamScope + } + if seenMembers[id] { + return nil, ErrTeamInput + } + seenMembers[id] = true + copy.MemberIDs = append(copy.MemberIDs, id) + } + next = append(next, copy) + } + for _, old := range all.Groups { + if seenGroups[old.ID] { + continue + } + if permission := editable[old.ID]; permission != nil { + if !permission.CanEdit { + return nil, ErrTeamScope + } + continue + } + copy := *old + copy.MemberIDs = append([]uint{}, old.MemberIDs...) + next = append(next, ©) + } + if _, err := uc.repo.SaveCatalog(ctx, revision, next); err != nil { + return nil, err + } + return uc.View(ctx) +} + +func (uc *TeamUsecase) UpdateProfile(ctx context.Context, profile MemberProfile) error { + view, err := uc.View(ctx) + if err != nil { + return err + } + var member *BusinessMember + for _, m := range view.Members { + if m.ID == profile.ID { + member = m + break + } + } + if member == nil || !member.CanEdit { + return ErrTeamScope + } + if profile.Password != "" { + hash, err := bcrypt.GenerateFromPassword([]byte(profile.Password), bcrypt.DefaultCost) + if err != nil { + return ErrMemberInput + } + profile.Password = string(hash) + } + return uc.repo.UpdateProfile(ctx, profile) +} + +func DefaultPerformanceRange(now time.Time) (time.Time, time.Time) { + now = now.In(portraitLocation) + begin := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, portraitLocation) + end := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, portraitLocation).AddDate(0, 0, -1) + return begin, end +} diff --git a/internal/data/business/account.go b/internal/data/business/account.go index b6ed321..cb732cb 100644 --- a/internal/data/business/account.go +++ b/internal/data/business/account.go @@ -161,6 +161,11 @@ func (r *accountRepo) Create(ctx context.Context, a *business.Account) error { if err := tx.Create(&po).Error; err != nil { return business.ErrStorage } + if a.HireDate != "" { + if err := tx.Create(&businessMemberProfilePO{UserID: po.ID, HireDate: a.HireDate, Employment: "employed"}).Error; err != nil { + return business.ErrStorage + } + } if err := tx.Table("sys_user_authority").Create(map[string]any{"sys_user_id": po.ID, "sys_authority_authority_id": po.AuthorityID}).Error; err != nil { return business.ErrStorage } diff --git a/internal/data/business/analytics.go b/internal/data/business/analytics.go index a749fe6..fa108fb 100644 --- a/internal/data/business/analytics.go +++ b/internal/data/business/analytics.go @@ -3,6 +3,7 @@ package business import ( "context" "sort" + "time" "golang.org/x/sync/errgroup" "gorm.io/gorm" @@ -218,6 +219,22 @@ func (r *analyticsRepo) Aggregate(ctx context.Context, kind business.AnalyticsKi if len(rowMap) == 0 { return result, nil } + if kind == business.ChannelAnalytics { + location := time.FixedZone("Asia/Shanghai", 8*3600) + begin, _ := time.ParseInLocation("2006-01-02", f.Begin, location) + end, _ := time.ParseInLocation("2006-01-02", f.End, location) + keys := []business.AnalyticsKey{} + for key := range rowMap { + keys = append(keys, key) + } + for start := 0; start < len(keys); start += 300 { + var count int64 + if err := db.Table("sys_business_channel_effectiveness e").Joins("JOIN sys_business_effectiveness_state s ON s.id=1 AND s.rule_key=e.rule_key").Where("(e.source_db,e.promote_id) IN ? AND e.qualified_at>=? AND e.qualified_at", analyticsPairs(keys[start:min(start+300, len(keys))]), begin.Unix(), end.AddDate(0, 0, 1).Unix()).Count(&count).Error; err != nil { + return nil, business.ErrStorage + } + result.EffectiveCount += count + } + } if kind == business.GameAnalytics { available, err := businessTableAvailable(db, "tab_game") if err != nil { diff --git a/internal/data/business/channel.go b/internal/data/business/channel.go index cc40b48..c0cb032 100644 --- a/internal/data/business/channel.go +++ b/internal/data/business/channel.go @@ -10,6 +10,7 @@ import ( ) type channelPO struct { + QualifiedAt *int64 `gorm:"->;-:migration"` ID uint64 SourceDB string `gorm:"column:source_db"` Account, Nickname, ParentName, RealName string @@ -21,7 +22,7 @@ type channelPO struct { func (channelPO) TableName() string { return "tab_promote" } func toBizChannel(p channelPO) *business.Channel { - return &business.Channel{ChannelKey: business.ChannelKey{SourceDB: p.SourceDB, ID: p.ID}, Account: p.Account, Nickname: p.Nickname, ParentName: p.ParentName, RealName: p.RealName, Level: p.PromoteLevel, RegisterType: p.RegisterType, Status: p.Status, LockStatus: p.LockStatus, ParentID: p.ParentID, TopPromoteID: p.TopPromoteID, BusierID: p.BusierID, CreatedUnix: p.CreateTime, LastLoginUnix: p.LastLoginTime} + return &business.Channel{ChannelKey: business.ChannelKey{SourceDB: p.SourceDB, ID: p.ID}, Account: p.Account, Nickname: p.Nickname, ParentName: p.ParentName, RealName: p.RealName, Level: p.PromoteLevel, RegisterType: p.RegisterType, Status: p.Status, LockStatus: p.LockStatus, ParentID: p.ParentID, TopPromoteID: p.TopPromoteID, BusierID: p.BusierID, CreatedUnix: p.CreateTime, LastLoginUnix: p.LastLoginTime, QualifiedAt: p.QualifiedAt} } type channelRepo struct{ data Provider } @@ -95,14 +96,22 @@ func (r *channelRepo) List(ctx context.Context, scope business.UserScope, filter q = q.Where("p."+field.column+" = ?", *field.value) } } + if filter.Effective != nil { + q = joinEffectiveness(q) + if *filter.Effective { + q = q.Where("e.qualified_at IS NOT NULL") + } else { + q = q.Where("e.qualified_at IS NULL") + } + } if err := q.Count(&page.Total).Error; err != nil { return nil, business.ErrStorage } var pos []channelPO o := business.NewListOptions(options...) keysQuery := q.Select("p.id,p.source_db").Order("p.create_time DESC,p.id DESC,p.source_db ASC").Offset(o.Offset).Limit(o.Limit) - pageQuery := db.Table("tab_promote p").Joins("JOIN (?) page_ids ON page_ids.id=p.id AND page_ids.source_db=p.source_db", keysQuery) - if err := pageQuery.Select("p.id,p.source_db,p.account,p.nickname,p.parent_name,p.real_name,p.promote_level,p.register_type,p.status,p.lock_status,p.parent_id,p.top_promote_id,p.busier_id").Order("p.create_time DESC,p.id DESC,p.source_db ASC").Scan(&pos).Error; err != nil { + pageQuery := joinEffectiveness(db.Table("tab_promote p").Joins("JOIN (?) page_ids ON page_ids.id=p.id AND page_ids.source_db=p.source_db", keysQuery)) + if err := pageQuery.Select("p.id,p.source_db,p.account,p.nickname,p.parent_name,p.real_name,p.promote_level,p.register_type,p.status,p.lock_status,p.parent_id,p.top_promote_id,p.busier_id,p.create_time,e.qualified_at").Order("p.create_time DESC,p.id DESC,p.source_db ASC").Scan(&pos).Error; err != nil { return nil, business.ErrStorage } if len(pos) == 0 { diff --git a/internal/data/business/dashboard.go b/internal/data/business/dashboard.go new file mode 100644 index 0000000..8872611 --- /dev/null +++ b/internal/data/business/dashboard.go @@ -0,0 +1,94 @@ +package business + +import ( + "context" + + "golang.org/x/sync/errgroup" + "kra/internal/biz/business" +) + +type dashboardRepo struct{ data Provider } + +func NewDashboardRepo(data Provider) business.DashboardRepo { return &dashboardRepo{data: data} } +func (r *dashboardRepo) Overview(ctx context.Context, scope business.AnalyticsScope, sources []string, w business.DashboardWindow) (*business.DashboardData, error) { + db := r.data.DB().WithContext(ctx) + out := &business.DashboardData{Current: business.DashboardMetrics{Pay: "0.00"}, Previous: business.DashboardMetrics{Pay: "0.00"}, Available: true} + empty, available, err := workbenchAvailable(db, scope, sources, "") + if err != nil { + return nil, err + } + out.Available = available + if empty || !available { + return out, nil + } + begin, end, previous := w.Begin.Format("2006-01-02"), w.End.Format("2006-01-02"), w.PreviousBegin.Format("2006-01-02") + f := business.AnalyticsFilter{Sources: sources, Begin: previous, End: end} + mode := 0 + if scope.IDCardDedup { + mode = 1 + } + var counts []struct { + Current bool + Metric int + Count int64 + } + var amounts struct{ CurrentPay, PreviousPay string } + type activeRow struct { + Current bool + SourceDB string `gorm:"column:source_db"` + PromoteID uint64 + } + var registered, paid []activeRow + var channels struct{ CurrentNew, PreviousNew, CurrentEffective, PreviousEffective int64 } + group, gctx := errgroup.WithContext(ctx) + group.SetLimit(2) + qdb := db.WithContext(gctx) + group.Go(func() error { + return storageError(analyticsQuery(qdb, scope, f, "tab_sw_report_day_key", mode).Where("r.metric IN (1,2)").Select("(r.stat_date>=?) AS current,r.metric,COUNT(DISTINCT "+portraitIdentity(qdb, scope.IDCardDedup)+") AS count", begin).Group("current,r.metric").Scan(&counts).Error) + }) + group.Go(func() error { + return storageError(analyticsQuery(qdb, scope, f, "tab_sw_report_day", 0).Select("CAST(COALESCE(SUM(CASE WHEN r.stat_date>=? THEN r.total_pay ELSE 0 END),0) AS CHAR) AS current_pay,CAST(COALESCE(SUM(CASE WHEN r.stat_date THEN r.total_pay ELSE 0 END),0) AS CHAR) AS previous_pay", begin, begin).Scan(&amounts).Error) + }) + group.Go(func() error { + return storageError(analyticsQuery(qdb, scope, f, "tab_sw_report_day_key", mode).Where("r.metric=1 AND r.identity_key IS NOT NULL").Select("DISTINCT (r.stat_date>=?) AS current,r.source_db,r.promote_id", begin).Scan(®istered).Error) + }) + group.Go(func() error { + return storageError(analyticsQuery(qdb, scope, f, "tab_sw_report_day", 0).Select("(r.stat_date>=?) AS current,r.source_db,r.promote_id", begin).Group("current,r.source_db,r.promote_id").Having("SUM(r.total_pay)>0").Scan(&paid).Error) + }) + group.Go(func() error { + q := analyticsBasePromotes(qdb, scope, f, "p") + q = joinEffectiveness(q) + return storageError(q.Select("COALESCE(SUM(CASE WHEN p.create_time>=? AND p.create_time THEN 1 ELSE 0 END),0) AS current_new,"+ + "COALESCE(SUM(CASE WHEN p.create_time>=? AND p.create_time THEN 1 ELSE 0 END),0) AS previous_new,"+ + "COALESCE(SUM(CASE WHEN e.qualified_at>=? AND e.qualified_at THEN 1 ELSE 0 END),0) AS current_effective,"+ + "COALESCE(SUM(CASE WHEN e.qualified_at>=? AND e.qualified_at THEN 1 ELSE 0 END),0) AS previous_effective", + w.Begin.Unix(), w.End.AddDate(0, 0, 1).Unix(), w.PreviousBegin.Unix(), w.Begin.Unix(), w.Begin.Unix(), w.End.AddDate(0, 0, 1).Unix(), w.PreviousBegin.Unix(), w.Begin.Unix()).Scan(&channels).Error) + }) + if err := group.Wait(); err != nil { + return nil, err + } + for _, c := range counts { + target := &out.Previous + if c.Current { + target = &out.Current + } + if c.Metric == 1 { + target.Register = c.Count + } else { + target.Active = c.Count + } + } + current, prior := map[business.ChannelKey]bool{}, map[business.ChannelKey]bool{} + for _, a := range append(registered, paid...) { + target := prior + if a.Current { + target = current + } + target[business.ChannelKey{SourceDB: a.SourceDB, ID: a.PromoteID}] = true + } + out.Current.ActiveChannels, out.Previous.ActiveChannels = int64(len(current)), int64(len(prior)) + out.Current.NewChannels, out.Previous.NewChannels = channels.CurrentNew, channels.PreviousNew + out.Current.Effective, out.Previous.Effective = channels.CurrentEffective, channels.PreviousEffective + out.Current.Pay, out.Previous.Pay = amounts.CurrentPay, amounts.PreviousPay + return out, nil +} diff --git a/internal/data/business/effectiveness.go b/internal/data/business/effectiveness.go new file mode 100644 index 0000000..0849f3e --- /dev/null +++ b/internal/data/business/effectiveness.go @@ -0,0 +1,198 @@ +package business + +import ( + "context" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + "kra/internal/biz/business" +) + +type effectivenessRepo struct{ data Provider } + +func NewEffectivenessRepo(data Provider) business.EffectivenessRepo { + return &effectivenessRepo{data: data} +} +func (r *effectivenessRepo) State(ctx context.Context) (*business.EffectivenessState, error) { + var po effectivenessStatePO + if err := r.data.DB().WithContext(ctx).First(&po, "id=1").Error; err != nil { + return nil, business.ErrStorage + } + s := &business.EffectivenessState{RuleKey: po.RuleKey, Revision: po.Revision} + if po.BuiltAt != nil { + s.BuiltAt = *po.BuiltAt + } + return s, nil +} +func (r *effectivenessRepo) Candidates(ctx context.Context, key string, duration, now int64, full bool) ([]*business.EffectivenessCandidate, error) { + db := r.data.DB().WithContext(ctx) + available, err := businessTablesAvailable(db, "tab_promote", "tab_spend") + if err != nil { + return nil, err + } + if !available { + return nil, business.ErrEffectivenessUnavailable + } + q := db.Table("tab_promote p").Joins("LEFT JOIN sys_business_channel_effectiveness e ON e.rule_key=? AND e.source_db=p.source_db AND e.promote_id=p.id", key).Where("p.source_db IN ?", []string{"lx", "hw", "yq"}) + if !full { + q = q.Where("e.promote_id IS NULL OR e.complete=?", false) + } + var pos []struct { + ID uint64 + SourceDB string `gorm:"column:source_db"` + CreateTime, QualifiedAt *int64 + WindowPay string + } + if err := q.Select("p.id,p.source_db,p.create_time,e.qualified_at,e.window_pay").Order("p.id,p.source_db").Scan(&pos).Error; err != nil { + return nil, business.ErrStorage + } + rows := make([]*business.EffectivenessCandidate, 0, len(pos)) + for _, po := range pos { + rows = append(rows, &business.EffectivenessCandidate{ChannelKey: business.ChannelKey{ID: po.ID, SourceDB: po.SourceDB}, CreatedAt: po.CreateTime, PreviousAt: po.QualifiedAt, PreviousPay: po.WindowPay}) + } + return rows, nil +} +func (r *effectivenessRepo) Charges(ctx context.Context, candidates []*business.EffectivenessCandidate, duration, now int64, full bool, consume func(business.EffectivenessCharge) error) error { + db := r.data.DB().WithContext(ctx) + // Bound by now as future charges are excluded. Avoid unsigned subtraction + // on legacy pay_time columns and overflow for very large configured windows. + windowSeconds := min(duration, now+1) + // The full-history scan is reserved for explicit rebuilds. Minute jobs + // must keep their candidate restriction in SQL, even with many open windows. + if full && len(candidates) > 3000 { + return r.scanAllCharges(db, candidates, windowSeconds, now, consume) + } + for start := 0; start < len(candidates); start += 300 { + keys := [][]any{} + ids := []uint64{} + for _, c := range candidates[start:min(start+300, len(candidates))] { + if c.CreatedAt != nil && *c.CreatedAt > 0 && *c.CreatedAt <= now { + keys = append(keys, []any{c.SourceDB, c.ID}) + ids = append(ids, c.ID) + } + } + if len(keys) == 0 { + continue + } + q := db.Table("tab_spend s").Joins("JOIN tab_promote p ON p.source_db=s.source_db AND p.id=s.promote_id").Where("s.promote_id IN ? AND (s.source_db,s.promote_id) IN ? AND s.pay_status=1", ids, keys).Where("s.pay_time>=p.create_time AND s.pay_time<=? AND s.pay_time
| {{ period.label }} | ||||||
|---|---|---|---|---|---|---|
| {{ metric.label }} | ||||||
| {{ row.groupName || '—' }} {{ row.memberCount }} 人 |
+ {{ row.name }} {{ businessLevels[row.member.level] }} |
+ {{ row.member?.hireDate || '—' }} | {{ row.member ? employmentLabels[row.member.employment] : '—' }} | ++ | {{ rateText(effectiveRate(row.total)) }} | ++ |
| 暂无符合条件的商务 | ||||||
| 筛选结果去重合计 | {{ displayMetric(metric.key, data?.matrixTotal?.[metric.key] ?? 0) }} | {{ data?.matrixRate == null ? '—' : `${data.matrixRate}%` }} | {{ displayMetric(metric.key, data?.matrixTrend?.[period.key]?.[metric.key] ?? 0) }} | |||
| {{ col.label }} | {{ period.label }} | |||||
|---|---|---|---|---|---|---|
| 目标 | 实际 | |||||
| └{{ row.name }} |
+ {{ row.member ? employmentLabels[row.member.employment] : `${row.memberCount} 人` }} | +{{ metric.label }}{{ displayTarget(metric.goal, row.goals?.[data.begin.slice(0,7)]?.[metric.goal]) }} |
+ {{ metric.label }}{{ displayMetric(metric.key, row.total[metric.key]) }} |
+ {{ metric.label }}{{ rateText(groupRate(row, metric)) }} |
+ {{ metric.label }}{{ displayTarget(metric.goal, periodTarget(row, period, metric)) }} | {{ metric.label }}— {{ rateText(periodRate(row, period)) }} |
+
| 暂无可见小组 | ||||||
合计:有效端