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;-: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=?) 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=? AND p.create_time=? AND e.qualified_at=? AND e.qualified_at 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_time0 AND s.pay_time>=p.create_time AND s.pay_time<=? AND s.pay_time 0 { + v, err := toBizOtherSettings(current) + if err != nil { + return err + } + active = *v + } + if business.EffectivenessRuleKey(active) != key { + return business.ErrEffectivenessConflict + } + } + now := time.Now() + for start := 0; start < len(rows); start += 500 { + pos := make([]effectivenessPO, 0, min(500, len(rows)-start)) + for _, row := range rows[start:min(start+500, len(rows))] { + pos = append(pos, effectivenessPO{RuleKey: key, SourceDB: row.SourceDB, PromoteID: row.ID, QualifiedAt: row.QualifiedAt, WindowPay: row.WindowPay, Complete: row.Complete, UpdatedAt: now}) + } + if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "rule_key"}, {Name: "source_db"}, {Name: "promote_id"}}, DoUpdates: clause.AssignmentColumns([]string{"qualified_at", "window_pay", "complete", "updated_at"})}).Create(&pos).Error; err != nil { + return err + } + } + if save { + po := newOtherSettings(rule) + var old otherSettingsPO + result := tx.Where(map[string]any{"key": otherSettingsKey}).Limit(1).Find(&old) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + if err := tx.Create(&po).Error; err != nil { + return err + } + } else { + if err := tx.Model(&old).Updates(map[string]any{"value": po.Value, "updated_at": now}).Error; err != nil { + return err + } + } + saved.UpdatedAt = now + } + return tx.Model(&effectivenessStatePO{}).Where("id=1").Updates(map[string]any{"rule_key": key, "revision": state.Revision + 1, "built_at": now}).Error + }) + if err != nil { + return nil, storageError(err) + } + return &saved, nil +} diff --git a/internal/data/business/management_models.go b/internal/data/business/management_models.go new file mode 100644 index 0000000..0f1fb36 --- /dev/null +++ b/internal/data/business/management_models.go @@ -0,0 +1,91 @@ +package business + +import ( + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + "kra/pkg/database/migration" +) + +type businessGroupPO struct { + ID uint64 `gorm:"primaryKey"` + Name string `gorm:"size:40;not null"` + OwnerID uint `gorm:"index"` + Sort int + CreatedAt, UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` +} + +func (businessGroupPO) TableName() string { return "sys_business_groups" } + +type businessGroupMemberPO struct { + UserID uint `gorm:"primaryKey;autoIncrement:false"` + GroupID uint64 `gorm:"index;not null"` +} + +func (businessGroupMemberPO) TableName() string { return "sys_business_group_members" } + +type businessMemberProfilePO struct { + UserID uint `gorm:"primaryKey;autoIncrement:false"` + HireDate string `gorm:"size:10"` + Employment string `gorm:"size:16;not null;default:employed"` + UpdatedAt time.Time +} + +func (businessMemberProfilePO) TableName() string { return "sys_business_member_profiles" } + +type businessGroupStatePO struct { + ID uint `gorm:"primaryKey;autoIncrement:false"` + Revision uint64 +} + +func (businessGroupStatePO) TableName() string { return "sys_business_group_state" } + +type performanceGoalPO struct { + Kind string `gorm:"primaryKey;size:8"` + TargetID uint64 `gorm:"primaryKey;autoIncrement:false"` + Month string `gorm:"primaryKey;size:7;index"` + Open, Effective, Amount *int64 + UpdatedBy uint + CreatedAt, UpdatedAt time.Time +} + +func (performanceGoalPO) TableName() string { return "sys_business_performance_goals" } + +type effectivenessStatePO struct { + ID uint `gorm:"primaryKey;autoIncrement:false"` + RuleKey string `gorm:"size:64"` + Revision uint64 + BuiltAt *time.Time +} + +func (effectivenessStatePO) TableName() string { return "sys_business_effectiveness_state" } + +type effectivenessPO struct { + RuleKey string `gorm:"primaryKey;size:64;index:idx_sw_effective_date,priority:1"` + // Platform codes are identifiers. Binary storage also avoids collation + // conflicts when joining historical SDK tables imported with another collation. + SourceDB string `gorm:"primaryKey;type:varbinary(10)"` + PromoteID uint64 `gorm:"primaryKey;autoIncrement:false"` + QualifiedAt *int64 `gorm:"index:idx_sw_effective_date,priority:2"` + WindowPay string `gorm:"type:decimal(28,2);not null;default:0"` + Complete bool + UpdatedAt time.Time +} + +func (effectivenessPO) TableName() string { return "sys_business_channel_effectiveness" } + +func migrateBusinessManagement(db *gorm.DB) error { + if err := migration.CreateMissingTables(db, &businessGroupPO{}, &businessGroupMemberPO{}, &businessMemberProfilePO{}, &businessGroupStatePO{}, &performanceGoalPO{}, &effectivenessStatePO{}, &effectivenessPO{}); err != nil { + return err + } + if err := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&businessGroupStatePO{ID: 1}).Error; err != nil { + return err + } + return db.Clauses(clause.OnConflict{DoNothing: true}).Create(&effectivenessStatePO{ID: 1}).Error +} + +func migrateEffectivenessQueryIndex(db *gorm.DB) error { + return migrateQueryIndexes(db, []businessQueryIndex{{"tab_spend", "idx_sw_spend_effective_scan", []string{"promote_id", "source_db", "pay_status", "pay_time", "id", "pay_amount"}}}) +} diff --git a/internal/data/business/management_test.go b/internal/data/business/management_test.go new file mode 100644 index 0000000..70dea5c --- /dev/null +++ b/internal/data/business/management_test.go @@ -0,0 +1,239 @@ +package business + +import ( + "context" + "errors" + "math/big" + "reflect" + "testing" + "time" + + "gorm.io/gorm" + "kra/internal/biz/business" + "kra/internal/biz/system" +) + +func TestEffectivenessRepositoryWindowAndAtomicRulePublication(t *testing.T) { + db, _, _, _ := prepareLedgerData(t) + if err := db.AutoMigrate(&otherSettingsPO{}); err != nil { + t.Fatal(err) + } + for _, sql := range []string{ + "INSERT INTO tab_spend(id,source_db,user_id,promote_id,pay_amount,pay_time,pay_status) VALUES(1001,'lx',1,100,5,87399,1),(1002,'lx',1,100,999,87400,1),(1003,'lx',1,100,999,900,1)", + } { + if err := db.Exec(sql).Error; err != nil { + t.Fatal(err) + } + } + repo := NewEffectivenessRepo(testData{db}) + settings := NewOtherSettingsRepo(testData{db}) + uc := business.NewEffectivenessUsecase(repo, settings) + ctx := system.NewActorContext(context.Background(), system.Actor{UserID: 1, AuthorityID: 888}) + rule := business.OtherSettings{Amount: 10, Days: 1} + if _, err := uc.Reconfigure(ctx, rule); err != nil { + t.Fatal(err) + } + var row effectivenessPO + if err := db.Where("rule_key=? AND source_db='lx' AND promote_id=100", business.EffectivenessRuleKey(rule)).First(&row).Error; err != nil { + t.Fatal(err) + } + if row.QualifiedAt == nil || *row.QualifiedAt != 1500 || portraitTestMoney(row.WindowPay) != "15.10" { + t.Fatalf("窗口须排除注册前、结束瞬间及失败订单: %+v", row) + } + var other effectivenessPO + if err := db.Where("rule_key=? AND source_db='hw' AND promote_id=100", business.EffectivenessRuleKey(rule)).First(&other).Error; err != nil || other.QualifiedAt == nil { + t.Fatal("同号渠道必须按平台分别判定") + } + if err := db.Exec("UPDATE tab_spend SET pay_status=0 WHERE source_db='lx' AND id=901").Error; err != nil { + t.Fatal(err) + } + if err := uc.Refresh(ctx, true); err != nil { + t.Fatal(err) + } + if err := db.Where("rule_key=? AND source_db='lx' AND promote_id=100", business.EffectivenessRuleKey(rule)).First(&row).Error; err != nil || row.QualifiedAt == nil || *row.QualifiedAt != 1500 { + t.Fatal("后续流水回落不能取消已达标标记") + } + next := business.OtherSettings{Amount: 20, Days: 1} + if _, err := uc.Reconfigure(ctx, next); err != nil { + t.Fatal(err) + } + var newRow effectivenessPO + if err := db.Where("rule_key=? AND source_db='lx' AND promote_id=100", business.EffectivenessRuleKey(next)).First(&newRow).Error; err != nil || newRow.QualifiedAt != nil { + t.Fatal("更改规则须按新门槛重算,而不是继承旧规则结果") + } + state, err := repo.State(ctx) + if err != nil { + t.Fatal(err) + } + if err := db.Callback().Create().Before("gorm:create").Register("fail_effectiveness", func(tx *gorm.DB) { + if tx.Statement.Table == "sys_business_channel_effectiveness" { + tx.AddError(errors.New("injected failure")) + } + }); err != nil { + t.Fatal(err) + } + defer db.Callback().Create().Remove("fail_effectiveness") + if _, err := uc.Reconfigure(ctx, business.OtherSettings{Amount: 100, Days: 1}); err == nil { + t.Fatal("注入的持久化失败必须上报") + } + stored, _ := settings.Find(ctx) + after, _ := repo.State(ctx) + if stored.Amount != 20 || after.Revision != state.Revision || after.RuleKey != state.RuleKey { + t.Fatal("失败不能发布规则或部分结果") + } +} + +func TestLargeEffectivenessScanMatchesBatchedCharges(t *testing.T) { + db, _, _, _ := prepareLedgerData(t) + if err := db.Exec("WITH RECURSIVE seq(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM seq WHERE n<3200) INSERT INTO tab_promote(id,source_db,account,promote_level,parent_id,top_promote_id,busier_id,create_time) SELECT 10000+n,'lx','bulk-'||n,1,0,0,14,1000 FROM seq").Error; err != nil { + t.Fatal(err) + } + r := &effectivenessRepo{data: testData{db}} + ctx := context.Background() + candidates, err := r.Candidates(ctx, "test", 86400, 90000, true) + if err != nil { + t.Fatal(err) + } + if len(candidates) <= 3000 { + t.Fatal("fixture must exercise streaming path") + } + var full, batched []business.EffectivenessCharge + if err := r.Charges(ctx, candidates, 86400, 90000, true, func(c business.EffectivenessCharge) error { full = append(full, c); return nil }); err != nil { + t.Fatal(err) + } + for start := 0; start < len(candidates); start += 300 { + if err := r.Charges(ctx, candidates[start:min(start+300, len(candidates))], 86400, 90000, false, func(c business.EffectivenessCharge) error { batched = append(batched, c); return nil }); err != nil { + t.Fatal(err) + } + } + if !reflect.DeepEqual(full, batched) { + t.Fatalf("流式优化不能改变时间顺序、平台身份或流水: full=%v batch=%v", full, batched) + } +} + +func TestPerformanceOwnerAggregationAndDrilldownUseSameScope(t *testing.T) { + db, _, scope, _ := prepareAnalyticsData(t) + repo := NewPerformanceRepo(testData{db}) + location := time.FixedZone("Asia/Shanghai", 8*3600) + begin := time.Date(2026, 11, 1, 0, 0, 0, 0, location) + end := begin.AddDate(0, 0, 2) + if err := db.Exec("UPDATE tab_promote SET create_time=? WHERE source_db='lx' AND id=100", begin.Unix()).Error; err != nil { + t.Fatal(err) + } + if err := db.Model(&effectivenessStatePO{}).Where("id=1").Updates(map[string]any{"rule_key": "fixture", "built_at": time.Now()}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&effectivenessPO{RuleKey: "fixture", SourceDB: "lx", PromoteID: 100, QualifiedAt: func() *int64 { n := begin.AddDate(0, 0, 1).Unix(); return &n }(), WindowPay: "3000"}).Error; err != nil { + t.Fatal(err) + } + facts, err := repo.Facts(context.Background(), scope.Viewer, nil, []string{"lx"}, begin, end) + if err != nil { + t.Fatal(err) + } + if len(facts.Cash) != 2 { + t.Fatalf("同一SDK商务应按日聚合,避免按渠道返回所有明细: %+v", facts.Cash) + } + if len(facts.Channels) != 1 || facts.Channels[0].ID != 100 { + t.Fatal("historical channels with no in-period opening/qualification must not be materialized") + } + total := new(big.Rat) + for _, cash := range facts.Cash { + if len(cash.OwnerIDs) != 1 || cash.OwnerIDs[0] != 9 { + t.Fatal("聚合丢失商务归属") + } + n, ok := new(big.Rat).SetString(cash.Pay) + if !ok { + t.Fatal("invalid amount") + } + total.Add(total, n) + } + if total.FloatString(2) != "1059.60" { + t.Fatalf("汇总金额改变: %v", total) + } + rows, count, err := repo.Details(context.Background(), scope.Viewer, business.PerformanceDetailSelection{OwnerIDs: []uint{9}}, []string{"lx"}, begin, end, "effective", 0, 20) + if err != nil || count != 1 || rows[0].ID != 100 || portraitTestMoney(rows[0].PeriodPay) != "10.10" { + t.Fatalf("下钻应准确筛选并只补本页充值: %+v %d %v", rows, count, err) + } + _, count, err = repo.Details(context.Background(), business.UserScope{OnlySelf: true, SelfID: 99}, business.PerformanceDetailSelection{OwnerIDs: []uint{9}}, nil, begin, end, "effective", 0, 20) + if err != nil || count != 0 { + t.Fatal("不能通过指定其他商务ID越权下钻") + } + selection := business.PerformanceDetailSelection{OwnerIDs: []uint{8, 9}, ContributorIDs: []uint{8, 9}} + rows, count, err = repo.Details(context.Background(), scope.Viewer, selection, []string{"lx"}, begin, end, "effective", 0, 20) + if err != nil || count != 2 || len(rows) != 2 || rows[0].ContributorID != 8 || rows[1].ContributorID != 9 || rows[0].ID != rows[1].ID || portraitTestMoney(rows[0].PeriodPay) != "10.10" || rows[0].ContributorName == "" { + t.Fatalf("team drilldown must show both member contributions: %+v count=%d err=%v", rows, count, err) + } + rows, count, err = repo.Details(context.Background(), scope.Viewer, selection, []string{"lx"}, begin, end, "effective", 1, 1) + if err != nil || count != 2 || len(rows) != 1 || rows[0].ContributorID != 9 { + t.Fatal("contribution pagination must preserve total and stable row identity") + } + data, err := NewDashboardRepo(testData{db}).Overview(context.Background(), scope, []string{"lx"}, business.DashboardWindow{Begin: begin, End: end, PreviousBegin: begin.AddDate(0, 0, -3), PreviousEnd: begin.AddDate(0, 0, -1)}) + if err != nil || data.Current.Effective != 1 || data.Current.ActiveChannels != 4 || portraitTestMoney(data.Current.Pay) != "1059.60" { + t.Fatalf("首页按区间统计与绩效口径不一致: %+v %v", data, err) + } +} + +func TestTeamCatalogVersionedMembershipAndIndependentGoals(t *testing.T) { + db, _ := prepareChannels(t) + repo := NewTeamRepo(testData{db}) + ctx := context.Background() + initial, err := repo.Catalog(ctx) + if err != nil { + t.Fatal(err) + } + view, err := repo.SaveCatalog(ctx, initial.Revision, []*business.BusinessGroup{{Name: "同名组", OwnerID: 7, MemberIDs: []uint{8}}, {Name: "同名组", OwnerID: 7, Sort: 1, MemberIDs: []uint{9}}}) + if err != nil || len(view.Groups) != 2 || view.Groups[0].ID == view.Groups[1].ID { + t.Fatal("同名组应以独立ID区分") + } + if _, err := repo.SaveCatalog(ctx, initial.Revision, nil); err != business.ErrTeamConflict { + t.Fatal("过期版本不能删除成员") + } + goals := NewGoalRepo(testData{db}) + target := int64(30) + if err := goals.Save(ctx, &business.PerformanceGoal{Kind: "person", TargetID: 9, Month: "2026-11", Open: &target}); err != nil { + t.Fatal(err) + } + if err := goals.Save(ctx, &business.PerformanceGoal{Kind: "group", TargetID: view.Groups[1].ID, Month: "2026-11", Open: &target}); err != nil { + t.Fatal(err) + } + zero := int64(0) + if err := goals.Save(ctx, &business.PerformanceGoal{Kind: "person", TargetID: 9, Month: "2026-11", Open: &zero}); err != nil { + t.Fatal(err) + } + values, err := goals.List(ctx, "2026-11", "2026-11") + if err != nil || len(values) != 2 { + t.Fatal("个人和小组目标必须各自保存") + } + for _, value := range values { + if value.Kind == "group" && *value.Open != 30 { + t.Fatal("修改个人目标不应改变团队目标") + } + if value.Kind == "person" && (*value.Open != 0 || value.Effective != nil) { + t.Fatal("零目标和留空必须区分") + } + } + if _, err := repo.SaveCatalog(ctx, view.Revision, []*business.BusinessGroup{{ID: view.Groups[0].ID, Name: "同名组", OwnerID: 7, MemberIDs: []uint{8, 9}}}); err != nil { + t.Fatal(err) + } + var members []businessGroupMemberPO + if err := db.Find(&members).Error; err != nil || len(members) != 2 { + t.Fatal("移动成员不能丢失或重复") + } + for _, member := range members { + if member.GroupID != view.Groups[0].ID { + t.Fatal("成员应移动到目标小组") + } + } + if err := repo.UpdateProfile(ctx, business.MemberProfile{ID: 9, RealName: "成员", Phone: "13900000009", HireDate: "2025-01-01", Employment: "departed"}); err != nil { + t.Fatal(err) + } + var user accountPO + if err := db.First(&user, 9).Error; err != nil || user.Enable != 0 || user.BusinessStatus != 1 { + t.Fatal("离职应停用登录但保留账号") + } + var total int64 + db.Table("tab_spend").Count(&total) + if total == 0 { + t.Fatal("离职不能删除历史业绩") + } +} diff --git a/internal/data/business/migrations.go b/internal/data/business/migrations.go index c35becd..0a59e55 100644 --- a/internal/data/business/migrations.go +++ b/internal/data/business/migrations.go @@ -6,7 +6,7 @@ import ( ) func Migrations() []migration.Step { - return []migration.Step{{ID: "202611040015_business_approved_game_index", Migrate: migrateApprovedGameIndexes}, {ID: "202611040014_business_report_build_indexes", Migrate: migrateReportBuildQueryIndexes}, {ID: "202611040013_business_query_indexes", Migrate: migrateBusinessQueryIndexes}, {ID: "202611040012_business_server_report_index", Migrate: migrateServerReportIndex}, {ID: "202611040011_business_registration_index", Migrate: migrateRegistrationIndex}, {ID: "202611040010_business_alert_trigger_locks", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &alertTriggerLockPO{}) }}, {ID: "202611040009_business_menu_icons", Migrate: migrateBusinessMenuIcons}, {ID: "202611040008_business_report_build_locks", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &reportBuildLockPO{}) }}, {ID: "202611030007_business_alert_attachments", Migrate: migrateAlertAttachments}, {ID: "202611030006_business_alert_schema", Migrate: migrateAlertSchema}, {ID: "202611030005_business_password_reset", Migrate: func(db *gorm.DB) error { + return []migration.Step{{ID: "202611040017_business_groups_goals_effectiveness", Migrate: migrateBusinessManagement}, {ID: "202611040018_business_effectiveness_query_index", Migrate: migrateEffectivenessQueryIndex}, {ID: "202611040015_business_approved_game_index", Migrate: migrateApprovedGameIndexes}, {ID: "202611040014_business_report_build_indexes", Migrate: migrateReportBuildQueryIndexes}, {ID: "202611040013_business_query_indexes", Migrate: migrateBusinessQueryIndexes}, {ID: "202611040012_business_server_report_index", Migrate: migrateServerReportIndex}, {ID: "202611040011_business_registration_index", Migrate: migrateRegistrationIndex}, {ID: "202611040010_business_alert_trigger_locks", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &alertTriggerLockPO{}) }}, {ID: "202611040009_business_menu_icons", Migrate: migrateBusinessMenuIcons}, {ID: "202611040008_business_report_build_locks", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &reportBuildLockPO{}) }}, {ID: "202611030007_business_alert_attachments", Migrate: migrateAlertAttachments}, {ID: "202611030006_business_alert_schema", Migrate: migrateAlertSchema}, {ID: "202611030005_business_password_reset", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &resetCodePO{}, &resetTokenPO{}) }}, {ID: "202611030004_business_binding_locks", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &bindingLockPO{}) diff --git a/internal/data/business/performance.go b/internal/data/business/performance.go new file mode 100644 index 0000000..15d7f88 --- /dev/null +++ b/internal/data/business/performance.go @@ -0,0 +1,232 @@ +package business + +import ( + "context" + "time" + + "gorm.io/gorm" + "kra/internal/biz/business" +) + +type performanceRepo struct{ data Provider } + +func NewPerformanceRepo(data Provider) business.PerformanceRepo { return &performanceRepo{data: data} } + +func performanceChannels(db *gorm.DB, scope business.UserScope, owners []uint, sources []string) *gorm.DB { + q := db.Table("tab_promote p").Where("p.source_db IN ?", []string{"lx", "hw", "yq"}) + if len(sources) > 0 { + q = q.Where("p.source_db IN ?", sources) + } + if !scope.All || owners != nil { + bindings := channelBindingQuery(db, scope, business.ChannelFilter{Sources: sources}).Select("b.source_db,b.platform_busier_id") + if owners != nil { + bindings = bindings.Where("b.user_id IN ?", owners) + } + q = q.Where("(p.source_db,p.busier_id) IN (?)", bindings) + } + return q +} +func performancePayQuery(db *gorm.DB, scope business.UserScope, owners []uint, sources []string, begin, end time.Time) *gorm.DB { + keys := performanceChannels(db, scope, owners, sources).Select("p.source_db,p.id") + return db.Table("tab_sw_report_day r").Where("r.dedup_mode=0 AND r.game_id>0 AND r.stat_date BETWEEN ? AND ?", begin.Format("2006-01-02"), end.Format("2006-01-02")).Where("(r.source_db,r.promote_id) IN (?)", keys) +} +func joinEffectiveness(q *gorm.DB) *gorm.DB { + return q.Joins("LEFT JOIN sys_business_effectiveness_state es ON es.id=1").Joins("LEFT JOIN sys_business_channel_effectiveness e ON e.rule_key=es.rule_key AND e.source_db=p.source_db AND e.promote_id=p.id") +} + +type performanceChannelPO struct { + ContributorID uint + ContributorName string + ID, BusierID uint64 + SourceDB string `gorm:"column:source_db"` + Account, Nickname string + CreateTime, QualifiedAt *int64 + WindowPay, PeriodPay string +} + +func performanceChannelToBiz(po performanceChannelPO) *business.PerformanceChannel { + return &business.PerformanceChannel{ContributorID: po.ContributorID, ContributorName: po.ContributorName, ChannelKey: business.ChannelKey{ID: po.ID, SourceDB: po.SourceDB}, Account: po.Account, Name: po.Nickname, CreatedAt: po.CreateTime, QualifiedAt: po.QualifiedAt, WindowPay: po.WindowPay, PeriodPay: po.PeriodPay, OwnerIDs: []uint{}} +} + +type performanceOwnerPO struct { + SourceDB string `gorm:"column:source_db"` + PlatformBusierID uint64 + UserID uint + Username, NickName string +} + +func performanceOwners(db *gorm.DB, scope business.UserScope, sources []string) (map[business.PlatformKey][]performanceOwnerPO, error) { + var rows []performanceOwnerPO + if err := channelBindingQuery(db, scope, business.ChannelFilter{Sources: sources}).Select("b.source_db,b.platform_busier_id,b.user_id,u.username,u.nick_name").Order("b.user_id").Scan(&rows).Error; err != nil { + return nil, business.ErrStorage + } + byKey := map[business.PlatformKey][]performanceOwnerPO{} + for _, row := range rows { + key := business.PlatformKey{SourceDB: row.SourceDB, BusierID: row.PlatformBusierID} + byKey[key] = append(byKey[key], row) + } + return byKey, nil +} +func (r *performanceRepo) Facts(ctx context.Context, scope business.UserScope, ownerIDs []uint, sources []string, begin, end time.Time) (*business.PerformanceFacts, error) { + var out *business.PerformanceFacts + err := r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var err error + out, err = readPerformanceFacts(tx, scope, ownerIDs, sources, begin, end) + return err + }) + return out, storageError(err) +} +func readPerformanceFacts(db *gorm.DB, scope business.UserScope, ownerIDs []uint, sources []string, begin, end time.Time) (*business.PerformanceFacts, error) { + out := &business.PerformanceFacts{Channels: []*business.PerformanceChannel{}, Cash: []business.PerformanceCash{}, Available: true} + available, err := businessTablesAvailable(db, "tab_promote", "tab_sw_report_day") + if err != nil { + return nil, err + } + out.Available = available + if !available { + return out, nil + } + owners, err := performanceOwners(db, scope, sources) + if err != nil { + return nil, err + } + var pos []performanceChannelPO + start, finish := begin.Unix(), min(end.AddDate(0, 0, 1).Unix(), time.Now().Unix()+1) + channelFacts := joinEffectiveness(performanceChannels(db, scope, ownerIDs, sources)).Where("(p.create_time>=? AND p.create_time=? AND e.qualified_at0 AND r.stat_date BETWEEN ? AND ?", begin.Format("2006-01-02"), end.Format("2006-01-02")).Select("r.source_db,owner_p.busier_id,CAST(r.stat_date AS CHAR) AS stat_date,CAST(SUM(r.total_pay) AS CHAR) AS pay").Group("r.source_db,owner_p.busier_id,r.stat_date").Scan(&cash).Error; err != nil { + return nil, business.ErrStorage + } + for _, row := range cash { + ids := []uint{} + for _, owner := range owners[business.PlatformKey{SourceDB: row.SourceDB, BusierID: row.BusierID}] { + ids = append(ids, owner.UserID) + } + out.Cash = append(out.Cash, business.PerformanceCash{ChannelKey: business.ChannelKey{SourceDB: row.SourceDB, ID: row.BusierID}, OwnerIDs: ids, Date: row.StatDate, Pay: row.Pay}) + } + return out, nil +} +func (r *performanceRepo) Details(ctx context.Context, scope business.UserScope, selection business.PerformanceDetailSelection, sources []string, begin, end time.Time, metric string, offset, limit int) ([]*business.PerformanceChannel, int64, error) { + db := r.data.DB().WithContext(ctx) + owners := selection.OwnerIDs + pay := performancePayQuery(db, scope, owners, sources, begin, end).Select("r.source_db,r.promote_id,SUM(r.total_pay) AS pay").Group("r.source_db,r.promote_id") + needsPay := metric == "pay" || metric == "all" + q := joinEffectiveness(performanceChannels(db, scope, owners, sources)) + contributorSelect, contributorOrder := "", "" + if len(selection.ContributorIDs) > 0 { + // Keep one row per member/platform owner, even if bindings overlap. + prefix := "(COALESCE(NULLIF(TRIM(c.id_path),''),'0') || ',' || c.id)" + pattern := "(" + prefix + " || ',%')" + if db.Dialector.Name() == "mysql" { + prefix = "CONCAT(COALESCE(NULLIF(TRIM(c.id_path),''),'0'),',',c.id)" + pattern = "CONCAT(" + prefix + ",',%')" + } + contributors := channelBindingQuery(db, scope, business.ChannelFilter{Sources: sources}). + Joins("JOIN sys_users c ON c.deleted_at IS NULL AND (c.id=u.id OR c.id=u.parent_id OR u.id_path="+prefix+" OR u.id_path LIKE "+pattern+")"). + Where("c.id IN ? AND u.deleted_at IS NULL", selection.ContributorIDs). + Select("DISTINCT b.source_db,b.platform_busier_id,c.id AS contributor_id,COALESCE(NULLIF(c.nick_name,''),c.username) AS contributor_name") + if owners != nil { + contributors = contributors.Where("b.user_id IN ?", owners) + } + q = q.Joins("JOIN (?) contribution ON contribution.source_db=p.source_db AND contribution.platform_busier_id=p.busier_id", contributors) + contributorSelect = ",contribution.contributor_id,contribution.contributor_name" + contributorOrder = ",contribution.contributor_id" + } + if needsPay { + q = q.Joins("LEFT JOIN (?) pp ON pp.source_db=p.source_db AND pp.promote_id=p.id", pay) + } + start, finish := begin.Unix(), min(end.AddDate(0, 0, 1).Unix(), time.Now().Unix()+1) + switch metric { + case "open": + q = q.Where("p.create_time>=? AND p.create_time=? AND e.qualified_at0") + default: + q = q.Where("(p.create_time>=? AND p.create_time=? AND e.qualified_at0", start, finish, start, finish) + } + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, business.ErrStorage + } + var pos []performanceChannelPO + paySelect := "'0.00' AS period_pay" + if needsPay { + paySelect = "CAST(COALESCE(pp.pay,0) AS CHAR) AS period_pay" + } + if err := q.Select("p.id,p.source_db,p.busier_id,p.account,p.nickname,p.create_time,e.qualified_at,CAST(COALESCE(e.window_pay,0) AS CHAR) AS window_pay," + paySelect + contributorSelect).Order("p.create_time DESC,p.source_db,p.id" + contributorOrder).Offset(offset).Limit(limit).Scan(&pos).Error; err != nil { + return nil, 0, business.ErrStorage + } + if !needsPay && len(pos) > 0 { + keys := [][]any{} + for _, po := range pos { + keys = append(keys, []any{po.SourceDB, po.ID}) + } + var amounts []struct { + SourceDB string `gorm:"column:source_db"` + PromoteID uint64 + Pay string + } + if err := db.Table("tab_sw_report_day r").Where("r.dedup_mode=0 AND r.game_id>0 AND r.stat_date BETWEEN ? AND ? AND (r.source_db,r.promote_id) IN ?", begin.Format("2006-01-02"), end.Format("2006-01-02"), keys).Select("r.source_db,r.promote_id,CAST(SUM(r.total_pay) AS CHAR) AS pay").Group("r.source_db,r.promote_id").Scan(&amounts).Error; err != nil { + return nil, 0, business.ErrStorage + } + byKey := map[business.ChannelKey]string{} + for _, a := range amounts { + byKey[business.ChannelKey{SourceDB: a.SourceDB, ID: a.PromoteID}] = a.Pay + } + for i := range pos { + if value, ok := byKey[business.ChannelKey{SourceDB: pos[i].SourceDB, ID: pos[i].ID}]; ok { + pos[i].PeriodPay = value + } + } + } + bindings, err := performanceOwners(db, scope, sources) + if err != nil { + return nil, 0, err + } + rows := make([]*business.PerformanceChannel, 0, len(pos)) + for _, po := range pos { + row := performanceChannelToBiz(po) + for _, owner := range bindings[business.PlatformKey{SourceDB: po.SourceDB, BusierID: po.BusierID}] { + row.OwnerIDs = append(row.OwnerIDs, owner.UserID) + if row.OwnerID == 0 { + row.OwnerID = owner.UserID + row.OwnerName = owner.NickName + if row.OwnerName == "" { + row.OwnerName = owner.Username + } + } + } + rows = append(rows, row) + } + return rows, total, nil +} diff --git a/internal/data/business/performance_goal.go b/internal/data/business/performance_goal.go new file mode 100644 index 0000000..5f50fc3 --- /dev/null +++ b/internal/data/business/performance_goal.go @@ -0,0 +1,32 @@ +package business + +import ( + "context" + "time" + + "gorm.io/gorm/clause" + "kra/internal/biz/business" +) + +type goalRepo struct{ data Provider } + +func NewGoalRepo(data Provider) business.GoalRepo { return &goalRepo{data: data} } +func (r *goalRepo) List(ctx context.Context, begin, end string) ([]*business.PerformanceGoal, error) { + var rows []performanceGoalPO + if err := r.data.DB().WithContext(ctx).Where("month BETWEEN ? AND ?", begin, end).Order("month DESC,kind,target_id").Find(&rows).Error; err != nil { + return nil, business.ErrStorage + } + out := make([]*business.PerformanceGoal, 0, len(rows)) + for _, r := range rows { + out = append(out, &business.PerformanceGoal{Kind: r.Kind, TargetID: r.TargetID, Month: r.Month, Open: r.Open, Effective: r.Effective, Amount: r.Amount, UpdatedBy: r.UpdatedBy, UpdatedAt: r.UpdatedAt}) + } + return out, nil +} +func (r *goalRepo) Save(ctx context.Context, g *business.PerformanceGoal) error { + db := r.data.DB().WithContext(ctx) + if g.Open == nil && g.Effective == nil && g.Amount == nil { + return storageError(db.Where("kind=? AND target_id=? AND month=?", g.Kind, g.TargetID, g.Month).Delete(&performanceGoalPO{}).Error) + } + po := performanceGoalPO{Kind: g.Kind, TargetID: g.TargetID, Month: g.Month, Open: g.Open, Effective: g.Effective, Amount: g.Amount, UpdatedBy: g.UpdatedBy, UpdatedAt: time.Now()} + return storageError(db.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "kind"}, {Name: "target_id"}, {Name: "month"}}, DoUpdates: clause.AssignmentColumns([]string{"open", "effective", "amount", "updated_by", "updated_at"})}).Create(&po).Error) +} diff --git a/internal/data/business/team.go b/internal/data/business/team.go new file mode 100644 index 0000000..ab493cc --- /dev/null +++ b/internal/data/business/team.go @@ -0,0 +1,172 @@ +package business + +import ( + "context" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + "kra/internal/biz/business" +) + +type teamRepo struct{ data Provider } + +func NewTeamRepo(data Provider) business.TeamRepo { return &teamRepo{data: data} } + +func (r *teamRepo) Members(ctx context.Context, scope business.UserScope) ([]*business.BusinessMember, error) { + db := r.data.DB().WithContext(ctx) + var rows []struct { + Account accountPO `gorm:"embedded"` + HireDate, Employment string + GroupID uint64 + } + q := applyUserScope(db.Table("sys_users u").Where("u.deleted_at IS NULL AND u.level BETWEEN 1 AND 3"), scope, "u.") + if err := q.Select("u.*,p.username AS parent_user_name,mp.hire_date,mp.employment,gm.group_id").Joins("LEFT JOIN sys_users p ON p.id=u.parent_id AND p.deleted_at IS NULL").Joins("LEFT JOIN sys_business_member_profiles mp ON mp.user_id=u.id").Joins("LEFT JOIN sys_business_group_members gm ON gm.user_id=u.id").Order("u.id").Scan(&rows).Error; err != nil { + return nil, business.ErrStorage + } + ids := make([]uint, 0, len(rows)) + for _, row := range rows { + ids = append(ids, row.Account.ID) + } + counts, err := (&accountRepo{data: r.data}).DirectCounts(ctx, ids) + if err != nil { + return nil, err + } + out := make([]*business.BusinessMember, 0, len(rows)) + for _, row := range rows { + a := toBizAccount(row.Account) + a.Password = "" + a.PromoteCount = counts[a.ID].PromoteCount + a.BindCount = counts[a.ID].BindCount + employment := row.Employment + if employment == "" { + employment = "employed" + if a.Status != 0 { + employment = "departed" + } + } + a.HireDate, a.Employment = row.HireDate, employment + out = append(out, &business.BusinessMember{Account: *a, GroupID: row.GroupID}) + } + return out, nil +} +func readTeamCatalog(db *gorm.DB) (*business.TeamCatalog, error) { + var state businessGroupStatePO + if err := db.First(&state, "id=1").Error; err != nil { + return nil, business.ErrStorage + } + var groups []businessGroupPO + if err := db.Order("sort,id").Find(&groups).Error; err != nil { + return nil, business.ErrStorage + } + var members []businessGroupMemberPO + if err := db.Order("user_id").Find(&members).Error; err != nil { + return nil, business.ErrStorage + } + byGroup := map[uint64][]uint{} + for _, m := range members { + byGroup[m.GroupID] = append(byGroup[m.GroupID], m.UserID) + } + out := &business.TeamCatalog{Revision: state.Revision, Groups: []*business.BusinessGroup{}} + for _, g := range groups { + out.Groups = append(out.Groups, &business.BusinessGroup{ID: g.ID, Name: g.Name, OwnerID: g.OwnerID, Sort: g.Sort, MemberIDs: append([]uint{}, byGroup[g.ID]...)}) + } + return out, nil +} +func (r *teamRepo) Catalog(ctx context.Context) (*business.TeamCatalog, error) { + var value *business.TeamCatalog + err := r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { var err error; value, err = readTeamCatalog(tx); return err }) + return value, storageError(err) +} +func (r *teamRepo) SaveCatalog(ctx context.Context, revision uint64, groups []*business.BusinessGroup) (*business.TeamCatalog, error) { + db := r.data.DB().WithContext(ctx) + err := db.Transaction(func(tx *gorm.DB) error { + updated := tx.Model(&businessGroupStatePO{}).Where("id=1 AND revision=?", revision).Update("revision", gorm.Expr("revision+1")) + if updated.Error != nil { + return updated.Error + } + if updated.RowsAffected != 1 { + return business.ErrTeamConflict + } + var current []businessGroupPO + if err := tx.Find(¤t).Error; err != nil { + return err + } + byID := map[uint64]businessGroupPO{} + for _, g := range current { + byID[g.ID] = g + } + ids := []uint64{} + members := []businessGroupMemberPO{} + for _, g := range groups { + po := businessGroupPO{ID: g.ID, Name: g.Name, OwnerID: g.OwnerID, Sort: g.Sort} + if g.ID == 0 { + if err := tx.Create(&po).Error; err != nil { + return err + } + } else { + old, exists := byID[g.ID] + if !exists { + return business.ErrTeamConflict + } + if old.Name != g.Name || old.Sort != g.Sort { + if err := tx.Model(&businessGroupPO{}).Where("id=?", g.ID).Updates(map[string]any{"name": g.Name, "sort": g.Sort, "updated_at": time.Now()}).Error; err != nil { + return err + } + } + } + ids = append(ids, po.ID) + for _, id := range g.MemberIDs { + members = append(members, businessGroupMemberPO{UserID: id, GroupID: po.ID}) + } + } + deleted := tx.Model(&businessGroupPO{}) + if len(ids) > 0 { + deleted = deleted.Where("id NOT IN ?", ids) + } else { + deleted = deleted.Where("1=1") + } + if err := deleted.Delete(&businessGroupPO{}).Error; err != nil { + return err + } + if err := tx.Where("1=1").Delete(&businessGroupMemberPO{}).Error; err != nil { + return err + } + if len(members) > 0 { + if err := tx.CreateInBatches(&members, 300).Error; err != nil { + return err + } + } + return nil + }) + if err != nil { + return nil, storageError(err) + } + return r.Catalog(ctx) +} +func (r *teamRepo) UpdateProfile(ctx context.Context, p business.MemberProfile) error { + return storageError(r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var account accountPO + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&account, p.ID).Error; err != nil { + return err + } + domain := toBizAccount(account) + domain.Phone = p.Phone + if err := checkAccountUnique(tx, domain); err != nil { + return err + } + status, enable := 0, 1 + if p.Employment == "departed" { + status, enable = 1, 0 + } + values := map[string]any{"real_name": p.RealName, "phone": p.Phone, "business_status": status, "enable": enable, "updated_at": time.Now()} + if p.Password != "" { + values["password"] = p.Password + values["password_updated_at"] = time.Now() + } + if err := tx.Model(&accountPO{}).Where("id=?", p.ID).Updates(values).Error; err != nil { + return err + } + return tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "user_id"}}, DoUpdates: clause.AssignmentColumns([]string{"hire_date", "employment", "updated_at"})}).Create(&businessMemberProfilePO{UserID: p.ID, HireDate: p.HireDate, Employment: p.Employment, UpdatedAt: time.Now()}).Error + })) +} diff --git a/internal/data/data.go b/internal/data/data.go index d632dad..a70cd31 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -30,6 +30,11 @@ var ProviderSet = wire.NewSet( wire.Bind(new(databusiness.PlatformProvider), new(*Data)), databusiness.NewUserPreferenceRepo, databusiness.NewOtherSettingsRepo, + databusiness.NewTeamRepo, + databusiness.NewGoalRepo, + databusiness.NewEffectivenessRepo, + databusiness.NewPerformanceRepo, + databusiness.NewDashboardRepo, databusiness.NewAccountRepo, databusiness.NewBindingRepo, databusiness.NewPlatformAccountRepo, diff --git a/internal/modules/business/definition.go b/internal/modules/business/definition.go index 38da59d..b52314c 100644 --- a/internal/modules/business/definition.go +++ b/internal/modules/business/definition.go @@ -1,15 +1,19 @@ package business import ( + "context" "gorm.io/gorm" databusiness "kra/internal/data/business" datasystem "kra/internal/data/system" + datatask "kra/internal/data/task" "kra/internal/modules/surface" "kra/pkg/database/migration" "kra/pkg/module" + "sort" ) func Definition() module.Definition { + performanceMenu := module.Menu{Name: "sw_BusinessPerformance", Path: "BusinessPerformance", ParentName: "sw_DataAnalysis", Component: "view/sw/BusinessPerformance.vue", Title: "商务绩效统计", Icon: "DataLine", Sort: 7} settingsMenu := module.Menu{Name: "sw_OtherSettings", Path: "OtherSettings", Component: "view/sw/OtherSettings.vue", Title: "其他设置", Icon: databusiness.MenuIcon("sw_OtherSettings"), Sort: 5} migrations := append(databusiness.Migrations(), migration.Step{ID: "202611040016_business_other_settings_surface", Migrate: func(db *gorm.DB) error { // First-install menus are created by the normal seed flow. @@ -51,8 +55,59 @@ func Definition() module.Definition { }, 888) }) }}) - return module.Definition{Name: "business", Migrations: migrations, Surface: module.Surface{ + managementAPIs := []module.API{} + for _, api := range surface.APIsForPrefix("/sw/", 0) { + switch api.Path { + case "/sw/groups", "/sw/business/profile", "/sw/business/management", "/sw/performance", "/sw/performance/details", "/sw/performance/export", "/sw/goals", "/sw/goals/mine", "/sw/workbench/overview": + managementAPIs = append(managementAPIs, api) + } + } + tasks := []module.TimedTask{{Name: "ChannelEffectiveness", Description: "增量刷新渠道有效端", Spec: "*/1 * * * *", MethodName: "channelEffectivenessTask", Enabled: true}, {Name: "ChannelEffectivenessRecheck", Description: "每日核对有效端历史流水", Spec: "0 3 * * *", MethodName: "channelEffectivenessFullTask", Enabled: true}} + migrations = append(migrations, migration.Step{ID: "202611040019_business_performance_surface", Migrate: func(db *gorm.DB) error { + if !db.Migrator().HasTable("sys_authorities") { + return nil + } + var count int64 + if err := db.Table("sys_authorities").Where("authority_id=888 AND deleted_at IS NULL").Count(&count).Error; err != nil { + return err + } + if count == 0 { + return nil + } + return db.Transaction(func(tx *gorm.DB) error { + if err := datasystem.EnsureAdminSurface(tx, module.Surface{Menus: []module.Menu{performanceMenu}, APIs: managementAPIs}, 888); err != nil { + return err + } + var roles []uint + if err := tx.Table("sys_authority_menus a").Joins("JOIN sys_base_menus m ON m.id=a.sys_base_menu_id").Where("m.name=? AND m.deleted_at IS NULL", "sw_BusinessList").Distinct().Pluck("a.sys_authority_authority_id", &roles).Error; err != nil { + return err + } + var menuIDs []uint + if err := tx.Table("sys_base_menus").Where("name IN ? AND deleted_at IS NULL", []string{"sw_DataAnalysis", performanceMenu.Name}).Pluck("id", &menuIDs).Error; err != nil { + return err + } + for _, role := range roles { + for _, id := range menuIDs { + var exists int64 + if err := tx.Table("sys_authority_menus").Where("sys_authority_authority_id=? AND sys_base_menu_id=?", role, id).Count(&exists).Error; err != nil { + return err + } + if exists == 0 { + if err := tx.Table("sys_authority_menus").Create(map[string]any{"sys_authority_authority_id": role, "sys_base_menu_id": id}).Error; err != nil { + return err + } + } + } + } + if err := datasystem.GrantMenuAPIs(tx, "sw_BusinessList", managementAPIs); err != nil { + return err + } + return datatask.SeedDefaults(context.Background(), tx, tasks) + }) + }}) + definition := module.Definition{Name: "business", Migrations: migrations, TimedTasks: tasks, Surface: module.Surface{ Menus: []module.Menu{ + performanceMenu, settingsMenu, {Name: "sw_RankingAnalysis", Path: "RankingAnalysis", ParentName: "sw_DataAnalysis", Component: "view/sw/RankingAnalysis.vue", Title: "排行榜", Icon: databusiness.MenuIcon("sw_RankingAnalysis"), Sort: 6}, {Name: "sw_ServerAnalysis", Path: "ServerAnalysis", ParentName: "sw_DataAnalysis", Component: "view/sw/ServerAnalysis.vue", Title: "区服分析", Icon: databusiness.MenuIcon("sw_ServerAnalysis"), Sort: 5}, @@ -71,4 +126,9 @@ func Definition() module.Definition { }, APIs: surface.APIsForPrefix("/sw/", 0), }} + // Parent menus must exist before their children in first-install seeding. + sort.SliceStable(definition.Surface.Menus, func(i, j int) bool { + return definition.Surface.Menus[i].ParentName == "" && definition.Surface.Menus[j].ParentName != "" + }) + return definition } diff --git a/internal/modules/business/definition_test.go b/internal/modules/business/definition_test.go index 1b275c0..6b91bff 100644 --- a/internal/modules/business/definition_test.go +++ b/internal/modules/business/definition_test.go @@ -7,6 +7,7 @@ import ( "github.com/glebarez/sqlite" "gorm.io/gorm" datasystem "kra/internal/data/system" + datatask "kra/internal/data/task" ) func TestOtherSettingsUpgradeAddsStandaloneMenuForSuperAdmin(t *testing.T) { @@ -16,7 +17,7 @@ func TestOtherSettingsUpgradeAddsStandaloneMenuForSuperAdmin(t *testing.T) { } pool, _ := db.DB() t.Cleanup(func() { _ = pool.Close() }) - for _, step := range datasystem.Migrations() { + for _, step := range append(datasystem.Migrations(), datatask.Migrations()...) { if err := step.Migrate(db); err != nil { t.Fatal(err) } diff --git a/internal/modules/catalog_test.go b/internal/modules/catalog_test.go index 1960a7c..2437c4f 100644 --- a/internal/modules/catalog_test.go +++ b/internal/modules/catalog_test.go @@ -25,18 +25,18 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) { t.Fatalf("definition[%d] = %q, want %q", index, got, name) } } - if got := catalog.MigrationSteps(); len(got) != 30 { - t.Fatalf("module migrations = %d, want 30", len(got)) + if got := catalog.MigrationSteps(); len(got) != 33 { + t.Fatalf("module migrations = %d, want 33", len(got)) } var menus, apis int for _, definition := range catalog.Definitions { menus += len(definition.Surface.Menus) apis += len(definition.Surface.APIs) } - if menus != 19 || apis != 71 { - t.Fatalf("admin surface = %d menus/%d APIs, want 19/71", menus, apis) + if menus != 20 || apis != 82 { + t.Fatalf("admin surface = %d menus/%d APIs, want 20/82", menus, apis) } - if got := catalog.DefaultTimedTasks(); len(got) != 2 { - t.Fatalf("default timed tasks = %d, want 2", len(got)) + if got := catalog.DefaultTimedTasks(); len(got) != 4 { + t.Fatalf("default timed tasks = %d, want 4", len(got)) } } diff --git a/internal/routecatalog/catalog.go b/internal/routecatalog/catalog.go index 400a742..6d43d2f 100644 --- a/internal/routecatalog/catalog.go +++ b/internal/routecatalog/catalog.go @@ -96,6 +96,17 @@ var routes = map[string]routeValue{ "PUT /sw/business": {group: "商务管理", description: "编辑商务资料和审核状态", audit: true}, "GET /sw/user/preference": {group: "商务偏好", description: "获取当前用户商务偏好"}, "GET /sw/settings/effectiveness": {group: "其他设置", description: "获取有效端判定参数"}, + "GET /sw/groups": {group: "商务小组", description: "查询可见小组与成员"}, + "PUT /sw/groups": {group: "商务小组", description: "保存小组配置", audit: true}, + "PUT /sw/business/profile": {group: "商务小组", description: "编辑商务档案", audit: true}, + "GET /sw/business/management": {group: "商务小组", description: "查询商务列表与当期绩效"}, + "GET /sw/performance": {group: "商务绩效", description: "查询商务及小组绩效"}, + "GET /sw/performance/details": {group: "商务绩效", description: "查询绩效渠道明细"}, + "GET /sw/performance/export": {group: "商务绩效", description: "导出商务及小组绩效", audit: true}, + "GET /sw/goals": {group: "绩效目标", description: "查询目标与历史达成"}, + "PUT /sw/goals": {group: "绩效目标", description: "保存月度目标", audit: true}, + "GET /sw/goals/mine": {group: "绩效目标", description: "查询本人本月目标"}, + "GET /sw/workbench/overview": {group: "商务工作台", description: "按时间区间查询首页指标"}, "PUT /sw/settings/effectiveness": {group: "其他设置", description: "保存有效端判定参数", audit: true}, "PUT /sw/user/preference": {group: "商务偏好", description: "更新当前用户商务偏好", audit: true}, "DELETE /api/deleteApisByIds": {group: "api", description: "批量删除api", audit: true}, diff --git a/internal/server/handler/performance.go b/internal/server/handler/performance.go new file mode 100644 index 0000000..714a4fb --- /dev/null +++ b/internal/server/handler/performance.go @@ -0,0 +1,139 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + "github.com/go-kratos/kratos/v3/errors" + "kra/internal/service/business" + "kra/internal/service/dto" +) + +type BusinessPerformance struct { + service *business.BusinessPerformanceService +} + +func (h *BusinessPerformance) Overview(c *gin.Context) { + var req dto.DashboardQuery + if c.ShouldBindQuery(&req) != nil { + Fail(c, "首页查询参数不正确") + return + } + v, err := h.service.Overview(c.Request.Context(), &req) + if err != nil { + Fail(c, errors.FromError(err).Message) + return + } + OKWithData(c, v) +} +func (h *BusinessPerformance) MyGoal(c *gin.Context) { + v, err := h.service.MyGoal(c.Request.Context()) + if err != nil { + Fail(c, errors.FromError(err).Message) + return + } + OKWithData(c, v) +} + +func NewBusinessPerformance(service *business.BusinessPerformanceService) *BusinessPerformance { + return &BusinessPerformance{service: service} +} +func (h *BusinessPerformance) Teams(c *gin.Context) { + v, err := h.service.Teams(c.Request.Context()) + if err != nil { + Fail(c, errors.FromError(err).Message) + return + } + OKWithData(c, v) +} +func (h *BusinessPerformance) SaveTeams(c *gin.Context) { + var req dto.GroupSaveRequest + if c.ShouldBindJSON(&req) != nil { + Fail(c, "小组参数不正确") + return + } + v, err := h.service.SaveTeams(c.Request.Context(), &req) + if err != nil { + Fail(c, errors.FromError(err).Message) + return + } + OKWithData(c, v) +} +func (h *BusinessPerformance) Profile(c *gin.Context) { + var req dto.MemberProfileRequest + if c.ShouldBindJSON(&req) != nil { + Fail(c, "商务档案参数不正确") + return + } + if err := h.service.UpdateProfile(c.Request.Context(), &req); err != nil { + Fail(c, errors.FromError(err).Message) + return + } + OK(c) +} +func (h *BusinessPerformance) Query(c *gin.Context) { h.query(c, false) } +func (h *BusinessPerformance) Management(c *gin.Context) { h.query(c, true) } +func (h *BusinessPerformance) query(c *gin.Context, management bool) { + var req dto.PerformanceQuery + if c.ShouldBindQuery(&req) != nil { + Fail(c, "绩效查询参数不正确") + return + } + v, err := h.service.Query(c.Request.Context(), &req, management) + if err != nil { + Fail(c, errors.FromError(err).Message) + return + } + OKWithData(c, v) +} +func (h *BusinessPerformance) Details(c *gin.Context) { + var req dto.PerformanceDetailQuery + if c.ShouldBindQuery(&req) != nil { + Fail(c, "明细查询参数不正确") + return + } + v, err := h.service.Details(c.Request.Context(), &req) + if err != nil { + Fail(c, errors.FromError(err).Message) + return + } + OKWithData(c, v) +} +func (h *BusinessPerformance) Goals(c *gin.Context) { + var req dto.GoalQuery + if c.ShouldBindQuery(&req) != nil { + Fail(c, "目标查询参数不正确") + return + } + v, err := h.service.GoalHistory(c.Request.Context(), &req) + if err != nil { + Fail(c, errors.FromError(err).Message) + return + } + OKWithData(c, v) +} +func (h *BusinessPerformance) SaveGoal(c *gin.Context) { + var req dto.GoalRequest + if c.ShouldBindJSON(&req) != nil { + Fail(c, "目标须为整数或留空") + return + } + if err := h.service.SaveGoal(c.Request.Context(), &req); err != nil { + Fail(c, errors.FromError(err).Message) + return + } + OK(c) +} +func (h *BusinessPerformance) Export(c *gin.Context) { + var req dto.PerformanceQuery + if c.ShouldBindQuery(&req) != nil { + Fail(c, "导出参数不正确") + return + } + data, err := h.service.Export(c.Request.Context(), &req) + if err != nil { + Fail(c, errors.FromError(err).Message) + return + } + c.Header("Content-Disposition", "attachment; filename=business-performance.xlsx") + c.Header("success", "true") + c.Data(200, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", data) +} diff --git a/internal/server/handler/provider.go b/internal/server/handler/provider.go index 1a2b5e2..dae0370 100644 --- a/internal/server/handler/provider.go +++ b/internal/server/handler/provider.go @@ -5,6 +5,7 @@ import "github.com/google/wire" // ProviderSet wires the system HTTP handlers. Set itself is filled field by // field so adding a handler only means adding its constructor here. var ProviderSet = wire.NewSet( + NewBusinessPerformance, NewOtherSettings, NewBusiness, NewBusinessPassword, diff --git a/internal/server/handler/set.go b/internal/server/handler/set.go index 6bf296f..15b5186 100644 --- a/internal/server/handler/set.go +++ b/internal/server/handler/set.go @@ -1,42 +1,43 @@ package handler type Set struct { - 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 *Business - BusinessPassword *BusinessPassword - SMS *SMS - Authority *Authority - Menu *Menu - API *API - Permission *Permission - Organization *Organization - Announcement *Announcement - Email *Email - Payment *Payment - Task *Task - 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 *Business + BusinessPassword *BusinessPassword + SMS *SMS + Authority *Authority + Menu *Menu + API *API + Permission *Permission + Organization *Organization + Announcement *Announcement + Email *Email + Payment *Payment + Task *Task + 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 } diff --git a/internal/server/router/performance.go b/internal/server/router/performance.go new file mode 100644 index 0000000..d9d27ca --- /dev/null +++ b/internal/server/router/performance.go @@ -0,0 +1,20 @@ +package router + +import ( + "github.com/gin-gonic/gin" + "kra/internal/server/handler" +) + +func RegisterBusinessPerformance(private *gin.RouterGroup, h *handler.BusinessPerformance) { + private.GET("/sw/workbench/overview", h.Overview) + private.GET("/sw/goals/mine", h.MyGoal) + private.GET("/sw/groups", h.Teams) + private.PUT("/sw/groups", h.SaveTeams) + private.PUT("/sw/business/profile", h.Profile) + private.GET("/sw/business/management", h.Management) + private.GET("/sw/performance", h.Query) + private.GET("/sw/performance/details", h.Details) + private.GET("/sw/performance/export", h.Export) + private.GET("/sw/goals", h.Goals) + private.PUT("/sw/goals", h.SaveGoal) +} diff --git a/internal/server/router/routes.go b/internal/server/router/routes.go index 6bc51ab..eaf8bc2 100644 --- a/internal/server/router/routes.go +++ b/internal/server/router/routes.go @@ -20,6 +20,7 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En return } registrations := []func(){ + func() { RegisterBusinessPerformance(private, r.handlers.BusinessPerformance) }, func() { RegisterOtherSettings(private, r.handlers.OtherSettings) }, func() { private.GET("/sw/rank/channel", r.handlers.Ranking.Channel) diff --git a/internal/service/business/account.go b/internal/service/business/account.go index d490ac6..df6829f 100644 --- a/internal/service/business/account.go +++ b/internal/service/business/account.go @@ -98,6 +98,17 @@ func validateAccount(req *dto.BusinessAccountRequest, create bool) (*int, error) if req == nil { return nil, business.ErrAccountInput } + if create && req.HireDate == nil { + return nil, business.ErrMemberInput + } + if req.Phone != nil && !validMemberPhone(*req.Phone) { + return nil, business.ErrMemberInput + } + if req.HireDate != nil { + if _, err := time.Parse("2006-01-02", *req.HireDate); err != nil { + return nil, business.ErrMemberInput + } + } if create && (req.Username == nil || req.RealName == nil || strings.TrimSpace(*req.RealName) == "" || req.Password == "") { return nil, business.ErrAccountInput } @@ -107,7 +118,7 @@ func validateAccount(req *dto.BusinessAccountRequest, create bool) (*int, error) for _, field := range []struct { s *string max int - }{{req.NickName, 30}, {req.RealName, 30}, {req.Phone, 11}, {req.Email, 50}, {req.QQNumber, 20}, {req.RejectReason, 200}} { + }{{req.NickName, 30}, {req.RealName, 30}, {req.Phone, 20}, {req.Email, 50}, {req.QQNumber, 20}, {req.RejectReason, 200}} { if field.s != nil && (utf8.RuneCountInString(*field.s) > field.max || strings.ContainsAny(*field.s, "<>")) { return nil, business.ErrAccountInput } @@ -147,6 +158,7 @@ func (s *AccountService) Create(ctx context.Context, req *dto.BusinessAccountReq } a := &business.Account{Username: stringValue(req.Username), NickName: stringValue(req.NickName), RealName: stringValue(req.RealName), Phone: stringValue(req.Phone), Email: stringValue(req.Email), QQNumber: stringValue(req.QQNumber), Password: req.Password, RejectReason: stringValue(req.RejectReason)} + a.HireDate = stringValue(req.HireDate) if status != nil { a.Status = *status } diff --git a/internal/service/business/account_test.go b/internal/service/business/account_test.go index b9d241c..f51310b 100644 --- a/internal/service/business/account_test.go +++ b/internal/service/business/account_test.go @@ -26,7 +26,7 @@ func (f *accountUsecaseFake) List(_ context.Context, filter business.AccountFilt func TestAccountTransportIgnoresForgedOwnershipAndPreservesDefaultStatus(t *testing.T) { var req dto.BusinessAccountRequest - if err := json.Unmarshal([]byte(`{"userId":999,"userName":"child","realName":"姓名","password":"abc123","level":1,"parentId":999,"roleIds":[888]}`), &req); err != nil { + if err := json.Unmarshal([]byte(`{"userId":999,"userName":"child","realName":"姓名","password":"abc123","hireDate":"2026-11-01","level":1,"parentId":999,"roleIds":[888]}`), &req); err != nil { t.Fatal(err) } f := &accountUsecaseFake{} @@ -67,3 +67,21 @@ func TestAccountQueryEmptyFiltersAndPageBounds(t *testing.T) { t.Fatalf("溢出分页应拒绝: %v", err) } } + +func TestCreateBusinessRequiresHireDateAndValidPhone(t *testing.T) { + f := &accountUsecaseFake{} + s := &AccountService{uc: f} + name, owner, phone, hire := "member", "负责人", "abc", "2026-11-01" + req := &dto.BusinessAccountRequest{Username: &name, RealName: &owner, Password: "test-password"} + if _, err := s.Create(context.Background(), req); err != business.ErrMemberInput || f.created != nil { + t.Fatal("missing hire date must not reach usecase") + } + req.HireDate, req.Phone = &hire, &phone + if _, err := s.Create(context.Background(), req); err != business.ErrMemberInput || f.created != nil { + t.Fatal("invalid phone must not reach usecase") + } + phone = "138 0013 8000" + if _, err := s.Create(context.Background(), req); err != nil || f.created.HireDate != hire { + t.Fatalf("valid profile rejected: %v", err) + } +} diff --git a/internal/service/business/analytics.go b/internal/service/business/analytics.go index 766d208..ce3bb84 100644 --- a/internal/service/business/analytics.go +++ b/internal/service/business/analytics.go @@ -75,6 +75,7 @@ func (s *AnalyticsService) Channels(ctx context.Context, req *dto.AnalyticsQuery result.Rows = append(result.Rows, channelAnalyticsRow(row, false)) } if page.Summary != nil { + result.EffectiveCount = &page.EffectiveCount result.Summary = channelAnalyticsRow(page.Summary, true) result.ChannelCount = &page.Total result.TotalRegisterCount = &page.Summary.Counts.Register diff --git a/internal/service/business/channel.go b/internal/service/business/channel.go index 3ef9c96..b8d4467 100644 --- a/internal/service/business/channel.go +++ b/internal/service/business/channel.go @@ -41,14 +41,14 @@ func (s *ChannelService) List(ctx context.Context, req *dto.ChannelQuery) (*dto. if page > int(^uint(0)>>1)/size { return nil, business.ErrChannelInput } - filter := business.ChannelFilter{Sources: sources, BusierUserID: req.BusierUserID, BusierAccount: strings.TrimSpace(req.BusierAccount), Keyword: strings.TrimSpace(req.Keyword), Level: req.Level, RegisterType: req.RegisterType, Status: req.Status, LockStatus: req.LockStatus} + filter := business.ChannelFilter{Sources: sources, BusierUserID: req.BusierUserID, BusierAccount: strings.TrimSpace(req.BusierAccount), Keyword: strings.TrimSpace(req.Keyword), Level: req.Level, RegisterType: req.RegisterType, Status: req.Status, LockStatus: req.LockStatus, Effective: req.Effective} result, err := s.uc.List(ctx, filter, business.ListOffset((page-1)*size), business.ListLimit(size)) if err != nil { return nil, err } out := &dto.ChannelPage{Rows: []*dto.ChannelResponse{}, Total: result.Total, Available: result.Available} for _, c := range result.Rows { - out.Rows = append(out.Rows, &dto.ChannelResponse{ID: c.ID, SourceDB: c.SourceDB, PlatformName: business.PlatformName(c.SourceDB), Account: c.Account, Nickname: c.Nickname, Level: c.Level, ParentID: c.ParentID, ParentName: c.ParentName, RealName: c.RealName, RegisterType: c.RegisterType, BusierID: c.BusierID, BusierUserID: c.BusierUserID, BusierDisplayName: c.BusinessDisplayName(), Status: c.Status, LockStatus: c.LockStatus, GameCount: c.GameCount, PlayerCount: c.PlayerCount, TotalPayAmount: c.TotalPayAmount}) + out.Rows = append(out.Rows, &dto.ChannelResponse{ID: c.ID, SourceDB: c.SourceDB, PlatformName: business.PlatformName(c.SourceDB), Account: c.Account, Nickname: c.Nickname, Level: c.Level, ParentID: c.ParentID, ParentName: c.ParentName, RealName: c.RealName, RegisterType: c.RegisterType, BusierID: c.BusierID, BusierUserID: c.BusierUserID, BusierDisplayName: c.BusinessDisplayName(), Status: c.Status, LockStatus: c.LockStatus, GameCount: c.GameCount, PlayerCount: c.PlayerCount, TotalPayAmount: c.TotalPayAmount, Effective: c.QualifiedAt != nil, QualifiedAt: c.QualifiedAt, CreatedAt: c.CreatedUnix}) } return out, nil } diff --git a/internal/service/business/performance.go b/internal/service/business/performance.go new file mode 100644 index 0000000..c9ffae0 --- /dev/null +++ b/internal/service/business/performance.go @@ -0,0 +1,296 @@ +package business + +import ( + "context" + "strconv" + "strings" + "time" + "unicode/utf8" + + "kra/internal/biz/business" + "kra/internal/service/dto" +) + +type TeamUsecase interface { + View(context.Context) (*business.TeamView, error) + Save(context.Context, uint64, []*business.BusinessGroup) (*business.TeamView, error) + UpdateProfile(context.Context, business.MemberProfile) error +} +type PerformanceUsecase interface { + Query(context.Context, business.PerformanceFilter) (*business.PerformancePage, error) + Details(context.Context, string, uint64, time.Time, time.Time, string, int, int, ...uint) ([]*business.PerformanceChannel, int64, error) +} +type GoalUsecase interface { + Mine(context.Context) (*business.GoalMonth, error) + Save(context.Context, *business.PerformanceGoal) error + History(context.Context, string, uint64) (*business.GoalPage, error) +} +type DashboardUsecase interface { + Overview(context.Context, []string, string, *time.Time, *time.Time) (*business.DashboardOverview, error) +} +type BusinessPerformanceService struct { + teams TeamUsecase + performance PerformanceUsecase + goals GoalUsecase + dashboard DashboardUsecase +} + +func NewBusinessPerformanceService(teams *business.TeamUsecase, performance *business.PerformanceUsecase, goals *business.GoalUsecase, dashboard *business.DashboardUsecase) *BusinessPerformanceService { + return &BusinessPerformanceService{teams: teams, performance: performance, goals: goals, dashboard: dashboard} +} + +func (s *BusinessPerformanceService) Overview(ctx context.Context, req *dto.DashboardQuery) (*dto.DashboardOverview, error) { + if req == nil { + return nil, business.ErrPerformanceInput + } + sources, err := normalizePlatforms(req.Sources) + if err != nil { + return nil, err + } + var begin, end *time.Time + if req.Period == "custom" { + location := time.FixedZone("Asia/Shanghai", 8*3600) + b, err := time.ParseInLocation("2006-01-02", req.Begin, location) + if err != nil { + return nil, business.ErrPerformanceInput + } + e, err := time.ParseInLocation("2006-01-02", req.End, location) + if err != nil || e.Before(b) { + return nil, business.ErrPerformanceInput + } + begin, end = &b, &e + } + v, err := s.dashboard.Overview(ctx, sources, req.Period, begin, end) + if err != nil { + return nil, err + } + r := &dto.DashboardOverview{Cards: map[string]*dto.WorkbenchCard{}, Begin: v.Begin, End: v.End, Settings: dto.OtherSettingsValues{Amount: v.Settings.Amount, Days: v.Settings.Days}, Available: v.Available} + for key, card := range v.Cards { + r.Cards[key] = workbenchCardResponse(card) + } + return r, nil +} +func (s *BusinessPerformanceService) MyGoal(ctx context.Context) (*dto.GoalMonthResponse, error) { + v, err := s.goals.Mine(ctx) + if err != nil { + return nil, err + } + if v == nil { + return nil, nil + } + return &dto.GoalMonthResponse{Month: v.Month, Goal: goalResponse(v.Goal), Actual: metricResponse(v.Actual), Available: v.Available}, nil +} +func memberResponse(m *business.BusinessMember) *dto.BusinessMemberResponse { + return &dto.BusinessMemberResponse{BusinessAccountResponse: accountResponse(&m.Account), HireDate: m.HireDate, Employment: m.Employment, GroupID: m.GroupID, GroupName: m.GroupName, CanEdit: m.CanEdit, CanSetGoal: m.CanSetGoal} +} +func groupResponse(g *business.BusinessGroup) *dto.BusinessGroupResponse { + return &dto.BusinessGroupResponse{ID: g.ID, Name: g.Name, MemberIDs: g.MemberIDs, CanEdit: g.CanEdit, CanSetGoal: g.CanSetGoal, Mine: g.Mine, Partial: g.Partial, Overlapping: g.Overlapping, Relation: g.Relation, ChannelCount: g.ChannelCount} +} +func teamResponse(v *business.TeamView) *dto.TeamResponse { + r := &dto.TeamResponse{Revision: v.Revision, Groups: []*dto.BusinessGroupResponse{}, Members: []*dto.BusinessMemberResponse{}, CanManage: v.CanManage, ViewerID: v.ViewerID} + for _, g := range v.Groups { + r.Groups = append(r.Groups, groupResponse(g)) + } + for _, m := range v.Members { + r.Members = append(r.Members, memberResponse(m)) + } + return r +} +func (s *BusinessPerformanceService) Teams(ctx context.Context) (*dto.TeamResponse, error) { + v, err := s.teams.View(ctx) + if err != nil { + return nil, err + } + return teamResponse(v), nil +} +func (s *BusinessPerformanceService) SaveTeams(ctx context.Context, req *dto.GroupSaveRequest) (*dto.TeamResponse, error) { + if req == nil || req.Revision == nil || req.Groups == nil { + return nil, business.ErrTeamInput + } + groups := []*business.BusinessGroup{} + for _, g := range req.Groups { + if g == nil || utf8.RuneCountInString(strings.TrimSpace(g.Name)) < 1 || utf8.RuneCountInString(strings.TrimSpace(g.Name)) > 10 { + return nil, business.ErrTeamInput + } + groups = append(groups, &business.BusinessGroup{ID: g.ID, Name: g.Name, MemberIDs: g.MemberIDs}) + } + v, err := s.teams.Save(ctx, *req.Revision, groups) + if err != nil { + return nil, err + } + return teamResponse(v), nil +} +func (s *BusinessPerformanceService) UpdateProfile(ctx context.Context, req *dto.MemberProfileRequest) error { + if req == nil || req.UserID == 0 || utf8.RuneCountInString(strings.TrimSpace(req.RealName)) < 1 || utf8.RuneCountInString(req.RealName) > 30 || !validMemberPhone(req.Phone) { + return business.ErrMemberInput + } + if _, err := time.Parse("2006-01-02", req.HireDate); err != nil { + return business.ErrMemberInput + } + if req.Employment != "employed" && req.Employment != "probation" && req.Employment != "departed" { + return business.ErrMemberInput + } + if req.Password != "" && (len(req.Password) > 72 || utf8.RuneCountInString(req.Password) < 5 || utf8.RuneCountInString(req.Password) > 50) { + return business.ErrMemberInput + } + return s.teams.UpdateProfile(ctx, business.MemberProfile{ID: req.UserID, RealName: strings.TrimSpace(req.RealName), Phone: strings.TrimSpace(req.Phone), HireDate: req.HireDate, Employment: req.Employment, Password: req.Password}) +} + +func validMemberPhone(value string) bool { + if len(value) > 20 { + return false + } + for _, r := range value { + if (r < '0' || r > '9') && r != ' ' && r != '-' { + return false + } + } + return true +} +func performanceRequest(req *dto.PerformanceQuery, management bool) (business.PerformanceFilter, error) { + if req == nil { + return business.PerformanceFilter{}, business.ErrPerformanceInput + } + if (req.BeginMonth == nil) != (req.EndMonth == nil) { + return business.PerformanceFilter{}, business.ErrPerformanceInput + } + f := business.PerformanceFilter{Name: strings.TrimSpace(req.Name), Level: req.Level, GroupID: req.GroupID, SortField: req.Sort, Ascending: req.Order == "asc", ForManagement: management} + if req.Year != nil { + if *req.Year < 1970 || *req.Year > 9999 { + return f, business.ErrPerformanceInput + } + f.Year = *req.Year + } + for _, pair := range []struct { + value *int + dst *int + }{{req.BeginMonth, &f.BeginMonth}, {req.EndMonth, &f.EndMonth}} { + if pair.value != nil { + if *pair.value < 1 || *pair.value > 12 { + return f, business.ErrPerformanceInput + } + *pair.dst = *pair.value + } + } + if req.Level != nil && (*req.Level < 1 || *req.Level > 3) { + return f, business.ErrPerformanceInput + } + return f, nil +} +func metricResponse(m business.PerformanceMetrics) dto.PerformanceMetrics { + return dto.PerformanceMetrics{Open: m.Open, Effective: m.Effective, Pay: m.Pay} +} +func metricsResponse(values map[string]business.PerformanceMetrics) map[string]dto.PerformanceMetrics { + r := map[string]dto.PerformanceMetrics{} + for key, m := range values { + r[key] = metricResponse(m) + } + return r +} +func goalResponse(g *business.PerformanceGoal) *dto.GoalResponse { + if g == nil { + return nil + } + return &dto.GoalResponse{Open: g.Open, Effective: g.Effective, Amount: g.Amount, UpdatedBy: g.UpdatedBy, UpdatedAt: g.UpdatedAt.Format(time.RFC3339)} +} +func goalsResponse(goals map[string]*business.PerformanceGoal) map[string]*dto.GoalResponse { + r := map[string]*dto.GoalResponse{} + for month, g := range goals { + r[month] = goalResponse(g) + } + return r +} +func performanceRowResponse(r *business.PerformanceRow) *dto.PerformanceRow { + return &dto.PerformanceRow{Member: memberResponse(r.Member), Total: metricResponse(r.Total), Rate: r.Rate, Periods: metricsResponse(r.Periods), Goals: goalsResponse(r.Goals), DescendantCount: r.DescendantCount} +} +func performanceResponse(v *business.PerformancePage) *dto.PerformancePage { + r := &dto.PerformancePage{Rows: []*dto.PerformanceRow{}, Groups: []*dto.PerformanceGroupRow{}, Total: metricResponse(v.Total), Rate: v.Rate, ProductiveMembers: v.ProductiveMembers, Prior: metricResponse(v.Prior), Periods: []dto.PerformancePeriod{}, Trend: metricsResponse(v.Trend), Begin: v.Begin, End: v.End, Granularity: v.Granularity, Settings: dto.OtherSettingsValues{Amount: v.Settings.Amount, Days: v.Settings.Days}, Available: v.Available} + r.MatrixTotal, r.MatrixRate, r.MatrixTrend = metricResponse(v.MatrixTotal), v.MatrixRate, metricsResponse(v.MatrixTrend) + for _, row := range v.Rows { + r.Rows = append(r.Rows, performanceRowResponse(row)) + } + for _, group := range v.Groups { + g := &dto.PerformanceGroupRow{Group: groupResponse(group.Group), Total: metricResponse(group.Total), UniqueTotal: metricResponse(group.UniqueTotal), Periods: metricsResponse(group.Periods), UniquePeriods: metricsResponse(group.UniquePeriods), Members: []*dto.PerformanceRow{}, Goals: goalsResponse(group.Goals)} + for _, member := range group.Members { + g.Members = append(g.Members, performanceRowResponse(member)) + } + r.Groups = append(r.Groups, g) + } + for _, p := range v.Periods { + r.Periods = append(r.Periods, dto.PerformancePeriod{Key: p.Key, Label: p.Label, Begin: p.Begin, End: p.End}) + } + return r +} +func (s *BusinessPerformanceService) Query(ctx context.Context, req *dto.PerformanceQuery, management bool) (*dto.PerformancePage, error) { + f, err := performanceRequest(req, management) + if err != nil { + return nil, err + } + v, err := s.performance.Query(ctx, f) + if err != nil { + return nil, err + } + return performanceResponse(v), nil +} +func (s *BusinessPerformanceService) Details(ctx context.Context, req *dto.PerformanceDetailQuery) (*dto.PerformanceDetailPage, error) { + if req == nil { + return nil, business.ErrPerformanceInput + } + location := time.FixedZone("Asia/Shanghai", 8*3600) + begin, err := time.ParseInLocation("2006-01-02", req.Begin, location) + if err != nil { + return nil, business.ErrPerformanceInput + } + end, err := time.ParseInLocation("2006-01-02", req.End, location) + if err != nil || end.Before(begin) { + return nil, business.ErrPerformanceInput + } + var members []uint + if req.MemberIDs != "" { + parts := strings.Split(req.MemberIDs, ",") + if len(parts) > 10000 { + return nil, business.ErrPerformanceInput + } + for _, part := range parts { + id, err := strconv.ParseUint(part, 10, strconv.IntSize) + if err != nil || id == 0 { + return nil, business.ErrPerformanceInput + } + members = append(members, uint(id)) + } + } + rows, total, err := s.performance.Details(ctx, req.Kind, req.TargetID, begin, end, req.Metric, req.Page, req.Size, members...) + if err != nil { + return nil, err + } + out := &dto.PerformanceDetailPage{Rows: []*dto.PerformanceChannel{}, Total: total} + for _, r := range rows { + out.Rows = append(out.Rows, &dto.PerformanceChannel{ContributorID: r.ContributorID, ContributorName: r.ContributorName, SourceDB: r.SourceDB, SourceDBLabel: business.PlatformName(r.SourceDB), ID: r.ID, Account: r.Account, Name: r.Name, OwnerName: r.OwnerName, CreatedAt: r.CreatedAt, QualifiedAt: r.QualifiedAt, WindowPay: r.WindowPay, PeriodPay: r.PeriodPay}) + } + return out, nil +} +func (s *BusinessPerformanceService) GoalHistory(ctx context.Context, req *dto.GoalQuery) (*dto.GoalPage, error) { + if req == nil { + return nil, business.ErrGoalInput + } + v, err := s.goals.History(ctx, req.Kind, req.TargetID) + if err != nil { + return nil, err + } + r := &dto.GoalPage{Kind: v.Kind, TargetID: v.TargetID, Name: v.Name, CanEdit: v.CanEdit, Months: []dto.GoalMonthResponse{}, MemberGoals: metricsResponse(v.MemberGoals)} + for _, m := range v.Months { + r.Months = append(r.Months, dto.GoalMonthResponse{Month: m.Month, Goal: goalResponse(m.Goal), Actual: metricResponse(m.Actual), Status: m.Status, Missing: m.Missing, CanEdit: m.CanEdit, Available: m.Available}) + } + return r, nil +} +func (s *BusinessPerformanceService) SaveGoal(ctx context.Context, req *dto.GoalRequest) error { + if req == nil { + return business.ErrGoalInput + } + for _, value := range []*int64{req.Open, req.Effective, req.Amount} { + if value != nil && (*value < 0 || *value > business.MaxSettingsInteger) { + return business.ErrGoalInput + } + } + return s.goals.Save(ctx, &business.PerformanceGoal{Kind: req.Kind, TargetID: req.TargetID, Month: req.Month, Open: req.Open, Effective: req.Effective, Amount: req.Amount}) +} diff --git a/internal/service/business/performance_excel.go b/internal/service/business/performance_excel.go new file mode 100644 index 0000000..d64484e --- /dev/null +++ b/internal/service/business/performance_excel.go @@ -0,0 +1,145 @@ +package business + +import ( + "context" + "strconv" + + "kra/internal/biz/business" + "kra/internal/service/dto" +) + +func (s *BusinessPerformanceService) Export(ctx context.Context, req *dto.PerformanceQuery) ([]byte, error) { + f, err := performanceRequest(req, false) + if err != nil { + return nil, err + } + data, err := s.performance.Query(ctx, f) + if err != nil { + return nil, err + } + if !data.Available { + return nil, business.ErrWorkbenchUnavailable + } + if len(data.Rows) > 10000 { + return nil, business.ErrLedgerExportLimit + } + if req.View == "groups" { + return exportGroupPerformance(data) + } + if req.View == "trend" { + return exportPerformanceTrend(data) + } + headers := []string{"小组", "商务", "入职日期", "在职状态", "区间开端", "区间有效端", "区间充值流水", "有效率"} + for i := len(data.Periods) - 1; i >= 0; i-- { + p := data.Periods[i] + headers = append(headers, p.Key+" 开端", p.Key+" 有效端", p.Key+" 充值流水") + } + rows := [][]ledgerCell{} + appendRow := func(group, name, hire, employment string, total business.PerformanceMetrics, rate *string, periods map[string]business.PerformanceMetrics) { + r := []ledgerCell{textCell(group), textCell(name), textCell(hire), textCell(employment), numericCell(strconv.FormatInt(total.Open, 10)), numericCell(strconv.FormatInt(total.Effective, 10)), numericCell(total.Pay), textCell("")} + if rate != nil { + r[7] = textCell(*rate + "%") + } + for i := len(data.Periods) - 1; i >= 0; i-- { + m := periods[data.Periods[i].Key] + r = append(r, numericCell(strconv.FormatInt(m.Open, 10)), numericCell(strconv.FormatInt(m.Effective, 10)), numericCell(m.Pay)) + } + rows = append(rows, r) + } + { + groups := map[uint64]*business.PerformanceGroupRow{} + for _, g := range data.Groups { + groups[g.Group.ID] = g + } + for i, r := range data.Rows { + name := r.Member.NickName + if name == "" { + name = r.Member.Username + } + appendRow(r.Member.GroupName, name, r.Member.HireDate, map[string]string{"employed": "在职", "probation": "试用", "departed": "离职"}[r.Member.Employment], r.Total, r.Rate, r.Periods) + if g := groups[r.Member.GroupID]; g != nil && (i == len(data.Rows)-1 || data.Rows[i+1].Member.GroupID != r.Member.GroupID) { + appendRow(g.Group.Name, g.Group.Name+" 小计", "", "", g.UniqueTotal, business.PerformanceRate(g.UniqueTotal), g.UniquePeriods) + } + } + } + appendRow("筛选结果去重合计", "上下级不重复计算", "", "", data.MatrixTotal, data.MatrixRate, data.MatrixTrend) + return ledgerWorkbook("商务绩效统计", headers, rows) +} + +func exportGroupPerformance(data *business.PerformancePage) ([]byte, error) { + headers := []string{"小组", "人数", "区间开端", "区间有效端", "区间流水", "开端完成率", "有效端完成率", "流水完成率"} + for i := len(data.Periods) - 1; i >= 0; i-- { + p := data.Periods[i] + for _, metric := range []string{"开端", "有效端", "流水"} { + headers = append(headers, p.Key+" "+metric+"目标", p.Key+" "+metric+"实际") + } + } + rows := [][]ledgerCell{} + for _, g := range data.Groups { + row := []ledgerCell{textCell(g.Group.Name), numericCell(strconv.Itoa(len(g.Group.MemberIDs))), numericCell(strconv.FormatInt(g.Total.Open, 10)), numericCell(strconv.FormatInt(g.Total.Effective, 10)), numericCell(g.Total.Pay)} + for _, key := range []string{"open", "effective", "pay"} { + rate := business.GoalCompletion(g.Periods, g.Goals, key) + if rate == nil { + row = append(row, textCell("—")) + } else { + row = append(row, textCell(*rate+"%")) + } + } + for i := len(data.Periods) - 1; i >= 0; i-- { + p := data.Periods[i] + goal := g.Goals[p.Key[:7]] + value := g.Periods[p.Key] + for _, key := range []string{"open", "effective", "pay"} { + target := business.GoalPeriodTarget(goal, key, p.Key, data.Granularity) + if target == nil { + row = append(row, textCell("—")) + } else { + row = append(row, numericCell(*target)) + } + if goal == nil { + row = append(row, textCell("—")) + continue + } + actual := value.Pay + if key == "open" { + actual = strconv.FormatInt(value.Open, 10) + } else if key == "effective" { + actual = strconv.FormatInt(value.Effective, 10) + } + row = append(row, numericCell(actual)) + } + } + rows = append(rows, row) + } + return ledgerWorkbook("小组绩效", headers, rows) +} + +func exportPerformanceTrend(data *business.PerformancePage) ([]byte, error) { + headers := []string{"日期/月份", "开端数", "有效端数", "充值流水", "有效率", "开端环比", "有效端环比", "流水环比"} + rows := [][]ledgerCell{} + for i := len(data.Periods) - 1; i >= 0; i-- { + p := data.Periods[i] + value := data.Trend[p.Key] + previous := data.Prior + if i > 0 { + previous = data.Trend[data.Periods[i-1].Key] + } + row := []ledgerCell{textCell(p.Key), numericCell(strconv.FormatInt(value.Open, 10)), numericCell(strconv.FormatInt(value.Effective, 10)), numericCell(value.Pay)} + rate := business.PerformanceRate(value) + if rate == nil { + row = append(row, textCell("—")) + } else { + row = append(row, textCell(*rate+"%")) + } + for _, key := range []string{"open", "effective", "pay"} { + change := business.PerformanceChange(value, previous, key) + if change == nil { + row = append(row, textCell("—")) + } else { + row = append(row, textCell(*change+"%")) + } + } + rows = append(rows, row) + } + return ledgerWorkbook("趋势图", headers, rows) +} diff --git a/internal/service/business/performance_test.go b/internal/service/business/performance_test.go new file mode 100644 index 0000000..41c6454 --- /dev/null +++ b/internal/service/business/performance_test.go @@ -0,0 +1,137 @@ +package business + +import ( + "bytes" + "context" + "testing" + "time" + + "github.com/xuri/excelize/v2" + "kra/internal/biz/business" + "kra/internal/service/dto" +) + +type performanceServiceFake struct { + data *business.PerformancePage + calls int + filter business.PerformanceFilter +} + +func (f *performanceServiceFake) Query(_ context.Context, filter business.PerformanceFilter) (*business.PerformancePage, error) { + f.calls++ + f.filter = filter + return f.data, nil +} +func (f *performanceServiceFake) Details(context.Context, string, uint64, time.Time, time.Time, string, int, int, ...uint) ([]*business.PerformanceChannel, int64, error) { + f.calls++ + return nil, 0, nil +} + +type goalServiceFake struct { + goal *business.PerformanceGoal + calls int +} + +type memberProfileFake struct { + TeamUsecase + profile *business.MemberProfile +} + +func (f *memberProfileFake) UpdateProfile(_ context.Context, value business.MemberProfile) error { + f.profile = &value + return nil +} + +func TestMemberProfileValidatesPhoneBeforeDelegating(t *testing.T) { + f := &memberProfileFake{} + s := &BusinessPerformanceService{teams: f} + req := &dto.MemberProfileRequest{UserID: 9, RealName: "成员", Phone: "abc", HireDate: "2026-11-01", Employment: "employed"} + if err := s.UpdateProfile(context.Background(), req); err != business.ErrMemberInput || f.profile != nil { + t.Fatal("invalid phone reached usecase") + } + for _, phone := range []string{"", "138 0013 8000", "010-12345678"} { + req.Phone = phone + if err := s.UpdateProfile(context.Background(), req); err != nil || f.profile.Phone != phone { + t.Fatalf("valid phone rejected: %q %v", phone, err) + } + } +} + +func (f *goalServiceFake) Save(_ context.Context, g *business.PerformanceGoal) error { + f.calls++ + f.goal = g + return nil +} +func (f *goalServiceFake) History(context.Context, string, uint64) (*business.GoalPage, error) { + return &business.GoalPage{}, nil +} +func (f *goalServiceFake) Mine(context.Context) (*business.GoalMonth, error) { return nil, nil } + +func TestPerformanceServiceValidatesDatesAndPreservesNullableGoals(t *testing.T) { + f := &performanceServiceFake{data: &business.PerformancePage{Available: true}} + g := &goalServiceFake{} + s := &BusinessPerformanceService{performance: f, goals: g} + one, bad := 1, 13 + for _, req := range []*dto.PerformanceQuery{nil, {BeginMonth: &one}, {BeginMonth: &one, EndMonth: &bad}, {Level: &bad}} { + if _, err := s.Query(context.Background(), req, false); err != business.ErrPerformanceInput { + t.Fatalf("无效查询应在进入用例前失败: %v", err) + } + } + if f.calls != 0 { + t.Fatal("无效查询访问了用例") + } + zero := int64(0) + negative := int64(-1) + if err := s.SaveGoal(context.Background(), &dto.GoalRequest{Kind: "person", TargetID: 9, Month: "2026-11", Open: &negative}); err != business.ErrGoalInput || g.calls != 0 { + t.Fatal("负数目标不应进入用例") + } + if err := s.SaveGoal(context.Background(), &dto.GoalRequest{Kind: "person", TargetID: 9, Month: "2026-11", Open: &zero}); err != nil || g.goal.Open == nil || *g.goal.Open != 0 || g.goal.Amount != nil { + t.Fatal("保存时应保留零和留空的差异") + } +} + +func TestPerformanceExportsMatchViewsAndKeepPreciseAmounts(t *testing.T) { + open, amount := int64(10), int64(3000) + periods := []business.PerformancePeriod{{Key: "2026-10", Label: "10月"}, {Key: "2026-11", Label: "11月"}} + values := map[string]business.PerformanceMetrics{"2026-10": {Open: 1, Effective: 0, Pay: "10.00"}, "2026-11": {Open: 12, Effective: 2, Pay: "9007199254740993.01"}} + member := &business.PerformanceRow{Member: &business.BusinessMember{Account: business.Account{ID: 9, Username: "=1+1"}, GroupID: 1, GroupName: "甲组"}, Total: values["2026-11"], Periods: values} + group := &business.PerformanceGroupRow{Group: &business.BusinessGroup{ID: 1, Name: "甲组", MemberIDs: []uint{9}}, Total: values["2026-11"], UniqueTotal: values["2026-11"], Periods: values, UniquePeriods: values, Members: []*business.PerformanceRow{member}, Goals: map[string]*business.PerformanceGoal{"2026-11": {Open: &open, Amount: &amount}}} + data := &business.PerformancePage{Available: true, Periods: periods, Rows: []*business.PerformanceRow{member}, Groups: []*business.PerformanceGroupRow{group}, Trend: values, Total: values["2026-11"], MatrixTotal: values["2026-11"], MatrixTrend: values, Granularity: "month", Prior: business.PerformanceMetrics{Pay: "0.00"}} + s := &BusinessPerformanceService{performance: &performanceServiceFake{data: data}} + for _, sample := range []struct { + view, sheet string + rows int + }{{"groups", "小组绩效", 2}, {"trend", "趋势图", 3}, {"business", "商务绩效统计", 4}} { + blob, err := s.Export(context.Background(), &dto.PerformanceQuery{View: sample.view}) + if err != nil { + t.Fatal(err) + } + file, err := excelize.OpenReader(bytes.NewReader(blob)) + if err != nil { + t.Fatal(err) + } + rows, err := file.GetRows(sample.sheet) + if err != nil || len(rows) != sample.rows { + t.Fatalf("视图导出行数不符或包含了展开成员: %s rows=%d %v", sample.view, len(rows), err) + } + if sample.view == "groups" { + value, _ := file.GetCellValue(sample.sheet, "N2", excelize.Options{RawCellValue: true}) + if value != "9007199254740993.01" { + t.Fatalf("金额不能经过浮点转换: %s", value) + } + if rows[1][14] != "—" || rows[1][15] != "—" { + t.Fatal("未设目标月份必须目标/实际分格占位") + } + } + if sample.view == "business" { + formula, _ := file.GetCellFormula(sample.sheet, "B2") + if formula != "" || rows[1][1] != "=1+1" { + t.Fatal("用户名称必须写成文本") + } + } + if sample.view == "trend" && rows[1][0] != "2026-11" { + t.Fatal("趋势导出应与页面倒序一致") + } + file.Close() + } +} diff --git a/internal/service/dto/analytics.go b/internal/service/dto/analytics.go index c47d16f..25cfbb9 100644 --- a/internal/service/dto/analytics.go +++ b/internal/service/dto/analytics.go @@ -39,6 +39,7 @@ type GameAnalyticsRow struct { ARPPU string `json:"arppu"` } type ChannelAnalyticsPage struct { + EffectiveCount *int64 `json:"effectiveCount"` Rows []*ChannelAnalyticsRow `json:"rows"` Total int64 `json:"total"` ChannelCount *int64 `json:"channelCount"` diff --git a/internal/service/dto/business_account.go b/internal/service/dto/business_account.go index 8f80084..6e9e596 100644 --- a/internal/service/dto/business_account.go +++ b/internal/service/dto/business_account.go @@ -17,6 +17,7 @@ type BusinessAccountQuery struct { } type BusinessAccountRequest struct { + HireDate *string `json:"hireDate"` UserID uint `json:"userId"` Username *string `json:"userName"` NickName *string `json:"nickName"` diff --git a/internal/service/dto/channel.go b/internal/service/dto/channel.go index 39ebf98..93ffec7 100644 --- a/internal/service/dto/channel.go +++ b/internal/service/dto/channel.go @@ -3,6 +3,7 @@ package dto import "encoding/json" type ChannelQuery struct { + Effective *bool `form:"effective"` Page int `form:"pageNum"` PageSize int `form:"pageSize"` Sources []string `form:"sourceDb"` @@ -15,6 +16,9 @@ type ChannelQuery struct { LockStatus *int `form:"lockStatus"` } type ChannelResponse struct { + Effective bool `json:"effective"` + QualifiedAt *int64 `json:"qualifiedAt"` + CreatedAt *int64 `json:"createdAt"` ID uint64 `json:"id"` SourceDB string `json:"sourceDb"` PlatformName string `json:"platformName"` diff --git a/internal/service/dto/performance.go b/internal/service/dto/performance.go new file mode 100644 index 0000000..1f3d500 --- /dev/null +++ b/internal/service/dto/performance.go @@ -0,0 +1,182 @@ +package dto + +type DashboardQuery struct { + Sources []string `form:"sourceDbList"` + Period string `form:"period"` + Begin string `form:"begin"` + End string `form:"end"` +} +type DashboardOverview struct { + Cards map[string]*WorkbenchCard `json:"cards"` + Begin string `json:"begin"` + End string `json:"end"` + Settings OtherSettingsValues `json:"settings"` + Available bool `json:"available"` +} + +type BusinessMemberResponse struct { + *BusinessAccountResponse + HireDate string `json:"hireDate"` + Employment string `json:"employment"` + GroupID uint64 `json:"groupId"` + GroupName string `json:"groupName"` + CanEdit bool `json:"canEdit"` + CanSetGoal bool `json:"canSetGoal"` +} +type BusinessGroupResponse struct { + ID uint64 `json:"id"` + Name string `json:"name"` + MemberIDs []uint `json:"memberIds"` + CanEdit bool `json:"canEdit"` + CanSetGoal bool `json:"canSetGoal"` + Mine bool `json:"mine"` + Partial bool `json:"partial"` + Overlapping bool `json:"overlapping"` + Relation string `json:"relation"` + ChannelCount int64 `json:"channelCount"` +} +type TeamResponse struct { + Revision uint64 `json:"revision"` + Groups []*BusinessGroupResponse `json:"groups"` + Members []*BusinessMemberResponse `json:"members"` + CanManage bool `json:"canManage"` + ViewerID uint `json:"viewerId"` +} +type GroupInput struct { + ID uint64 `json:"id"` + Name string `json:"name"` + MemberIDs []uint `json:"memberIds"` +} +type GroupSaveRequest struct { + Revision *uint64 `json:"revision"` + Groups []*GroupInput `json:"groups"` +} +type MemberProfileRequest struct { + UserID uint `json:"userId"` + RealName string `json:"realName"` + Phone string `json:"phoneNumber"` + HireDate string `json:"hireDate"` + Employment string `json:"employment"` + Password string `json:"password"` +} +type PerformanceQuery struct { + Year *int `form:"year"` + BeginMonth *int `form:"beginMonth"` + EndMonth *int `form:"endMonth"` + Name string `form:"name"` + Level *int `form:"level"` + GroupID *uint64 `form:"groupId"` + Sort string `form:"sort"` + Order string `form:"order"` + View string `form:"view"` +} +type PerformanceMetrics struct { + Open int64 `json:"open"` + Effective int64 `json:"effective"` + Pay string `json:"pay"` +} +type PerformancePeriod struct { + Key string `json:"key"` + Label string `json:"label"` + Begin string `json:"begin"` + End string `json:"end"` +} +type GoalResponse struct { + Open *int64 `json:"open"` + Effective *int64 `json:"effective"` + Amount *int64 `json:"amount"` + UpdatedBy uint `json:"updatedBy"` + UpdatedAt string `json:"updatedAt"` +} +type PerformanceRow struct { + Member *BusinessMemberResponse `json:"member"` + Total PerformanceMetrics `json:"total"` + Rate *string `json:"rate"` + Periods map[string]PerformanceMetrics `json:"periods"` + Goals map[string]*GoalResponse `json:"goals"` + DescendantCount int `json:"descendantCount"` +} +type PerformanceGroupRow struct { + Group *BusinessGroupResponse `json:"group"` + Total PerformanceMetrics `json:"total"` + UniqueTotal PerformanceMetrics `json:"uniqueTotal"` + Periods map[string]PerformanceMetrics `json:"periods"` + UniquePeriods map[string]PerformanceMetrics `json:"uniquePeriods"` + Members []*PerformanceRow `json:"members"` + Goals map[string]*GoalResponse `json:"goals"` +} +type PerformancePage struct { + MatrixTotal PerformanceMetrics `json:"matrixTotal"` + MatrixRate *string `json:"matrixRate"` + MatrixTrend map[string]PerformanceMetrics `json:"matrixTrend"` + Prior PerformanceMetrics `json:"prior"` + Rows []*PerformanceRow `json:"rows"` + Groups []*PerformanceGroupRow `json:"groups"` + Total PerformanceMetrics `json:"total"` + Rate *string `json:"rate"` + ProductiveMembers int64 `json:"productiveMembers"` + Periods []PerformancePeriod `json:"periods"` + Trend map[string]PerformanceMetrics `json:"trend"` + Begin string `json:"begin"` + End string `json:"end"` + Granularity string `json:"granularity"` + Settings OtherSettingsValues `json:"settings"` + Available bool `json:"available"` +} +type PerformanceDetailQuery struct { + MemberIDs string `form:"memberIds"` + Kind string `form:"kind"` + TargetID uint64 `form:"targetId"` + Begin string `form:"begin"` + End string `form:"end"` + Metric string `form:"metric"` + Page int `form:"page"` + Size int `form:"size"` +} +type PerformanceChannel struct { + ContributorID uint `json:"contributorId"` + ContributorName string `json:"contributorName"` + SourceDB string `json:"sourceDb"` + SourceDBLabel string `json:"sourceDbLabel"` + ID uint64 `json:"id"` + Account string `json:"account"` + Name string `json:"name"` + OwnerName string `json:"ownerName"` + CreatedAt *int64 `json:"createdAt"` + QualifiedAt *int64 `json:"qualifiedAt"` + WindowPay string `json:"windowPay"` + PeriodPay string `json:"periodPay"` +} +type PerformanceDetailPage struct { + Rows []*PerformanceChannel `json:"rows"` + Total int64 `json:"total"` +} +type GoalQuery struct { + Kind string `form:"kind"` + TargetID uint64 `form:"targetId"` +} +type GoalRequest struct { + Kind string `json:"kind"` + TargetID uint64 `json:"targetId"` + Month string `json:"month"` + Open *int64 `json:"open"` + Effective *int64 `json:"effective"` + Amount *int64 `json:"amount"` +} +type GoalMonthResponse struct { + Available bool `json:"available"` + Month string `json:"month"` + Goal *GoalResponse `json:"goal"` + Actual PerformanceMetrics `json:"actual"` + Status string `json:"status"` + Missing int `json:"missing"` + CanEdit bool `json:"canEdit"` +} +type GoalPage struct { + Kind string `json:"kind"` + TargetID uint64 `json:"targetId"` + Name string `json:"name"` + CanEdit bool `json:"canEdit"` + Months []GoalMonthResponse `json:"months"` + MemberGoals map[string]PerformanceMetrics `json:"memberGoals"` +} diff --git a/internal/service/service.go b/internal/service/service.go index 24b5017..c0eabf9 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -14,6 +14,7 @@ import ( var ProviderSet = wire.NewSet( businessservice.NewUserPreferenceService, businessservice.NewOtherSettingsService, + businessservice.NewBusinessPerformanceService, businessservice.NewAccountService, businessservice.NewBindingService, businessservice.NewPasswordResetService, diff --git a/internal/worker/task_registry.go b/internal/worker/task_registry.go index 4a7c7c5..4601dd6 100644 --- a/internal/worker/task_registry.go +++ b/internal/worker/task_registry.go @@ -15,16 +15,17 @@ import ( // TaskMethods owns the dependency-bearing built-in task handlers. type TaskMethods struct { - tasks *taskbiz.TaskUsecase - maintenance *system.MaintenanceUsecase - media *system.MediaUsecase - runtime *config.Store - reports *business.ReportBuildUsecase - alerts *business.AlertTriggerUsecase + tasks *taskbiz.TaskUsecase + maintenance *system.MaintenanceUsecase + media *system.MediaUsecase + runtime *config.Store + reports *business.ReportBuildUsecase + alerts *business.AlertTriggerUsecase + effectiveness *business.EffectivenessUsecase } -func NewTaskMethods(tasks *taskbiz.TaskUsecase, maintenance *system.MaintenanceUsecase, media *system.MediaUsecase, runtime *config.Store, reports *business.ReportBuildUsecase, alerts *business.AlertTriggerUsecase) *TaskMethods { - return &TaskMethods{tasks: tasks, maintenance: maintenance, media: media, runtime: runtime, reports: reports, alerts: alerts} +func NewTaskMethods(tasks *taskbiz.TaskUsecase, maintenance *system.MaintenanceUsecase, media *system.MediaUsecase, runtime *config.Store, reports *business.ReportBuildUsecase, alerts *business.AlertTriggerUsecase, effectiveness *business.EffectivenessUsecase) *TaskMethods { + return &TaskMethods{tasks: tasks, maintenance: maintenance, media: media, runtime: runtime, reports: reports, alerts: alerts, effectiveness: effectiveness} } func (methods *TaskMethods) RegisterTasks(registry *platformtask.Registry) { @@ -32,6 +33,28 @@ func (methods *TaskMethods) RegisterTasks(registry *platformtask.Registry) { return } methods.registerBusinessReports(registry) + for _, job := range []struct { + name, description string + full bool + }{{"channelEffectivenessTask", "增量刷新渠道有效端", false}, {"channelEffectivenessFullTask", "全量核对有效端,补齐延迟到达的历史流水", true}} { + registry.Register(platformtask.Method{Name: job.name, Description: job.description, Run: func(ctx context.Context, params json.RawMessage) error { + if methods.effectiveness == nil { + return errors.New("有效端任务依赖未就绪") + } + if ctx == nil { + ctx = context.Background() + } + var args struct { + Full bool `json:"full"` + } + if len(params) > 0 { + if err := json.Unmarshal(params, &args); err != nil { + return err + } + } + return methods.effectiveness.Refresh(ctx, job.full || args.Full) + }}) + } registry.Register(platformtask.Method{Name: "alertTriggerTask", Description: "按商务范围检测并生成预警", Run: func(ctx context.Context, _ json.RawMessage) error { if methods.alerts == nil { return errors.New("预警任务依赖未就绪") diff --git a/web/src/api/performance.js b/web/src/api/performance.js new file mode 100644 index 0000000..4d96314 --- /dev/null +++ b/web/src/api/performance.js @@ -0,0 +1,13 @@ +import service from '@/utils/request' +const get = (url, params) => service({ url, params, method: 'get', donNotShowLoading: true }) +export const getTeams = () => get('/sw/groups') +export const saveTeams = data => service({ url: '/sw/groups', method: 'put', data }) +export const updateMember = data => service({ url: '/sw/business/profile', method: 'put', data }) +export const getManagement = params => get('/sw/business/management', params) +export const getPerformance = params => get('/sw/performance', params) +export const getPerformanceDetails = params => get('/sw/performance/details', params) +export const getGoals = params => get('/sw/goals', params) +export const saveGoal = data => service({ url: '/sw/goals', method: 'put', data }) +export const getMyGoal = () => get('/sw/goals/mine') +export const getDashboardOverview = params => get('/sw/workbench/overview', params) +export const exportPerformance = params => service({ url: '/sw/performance/export', method: 'get', params, responseType: 'blob' }) diff --git a/web/src/pathInfo.json b/web/src/pathInfo.json index 858a5a7..c3254b0 100644 --- a/web/src/pathInfo.json +++ b/web/src/pathInfo.json @@ -55,6 +55,7 @@ "/src/view/sw/AlertList.vue": "SwAlertList", "/src/view/sw/AlertRules.vue": "SwAlertRules", "/src/view/sw/BusinessList.vue": "SwBusinessList", + "/src/view/sw/BusinessPerformance.vue": "SwBusinessPerformance", "/src/view/sw/ChannelActivationList.vue": "SwChannelActivationList", "/src/view/sw/ChannelDataStatistics.vue": "SwChannelDataStatistics", "/src/view/sw/ChannelList.vue": "SwChannelList", diff --git a/web/src/view/dashboard/components/BusinessDashboard.vue b/web/src/view/dashboard/components/BusinessDashboard.vue index 0525f20..5bfa04d 100644 --- a/web/src/view/dashboard/components/BusinessDashboard.vue +++ b/web/src/view/dashboard/components/BusinessDashboard.vue @@ -9,9 +9,14 @@ + +
+ {{ option.label }}自定义 + +
-
{{ card.label }}
+
{{ card.label }}
{{ metricValue(card) }}
{{ metricCompare(card.key) }}
@@ -38,7 +43,9 @@ import { onMounted, reactive, ref } from 'vue' import { useRouter } from 'vue-router' import { ElMessage } from 'element-plus' -import { getWorkbenchSummary, getWorkbenchRanks } from '@/api/workbench' +import { getWorkbenchRanks } from '@/api/workbench' +import { getDashboardOverview } from '@/api/performance' +import MyGoalCard from '@/view/sw/components/MyGoalCard.vue' import { listAlertRecords } from '@/api/alert' import { channelCount, channelMoney } from '@/view/sw/channel-display' import CompactRankingTable from '@/view/sw/components/CompactRankingTable.vue' @@ -46,9 +53,10 @@ import CompactRankingTable from '@/view/sw/components/CompactRankingTable.vue' const router = useRouter(), carousel = ref() const platforms = { lx: '龙翔', hw: '汇玩', yq: '游趣' } const sources = ref(Object.keys(platforms)), summary = ref(null), summaryLoading = ref(false), summaryError = ref('') +const overviewPeriod = ref('today'), overviewDates = ref([]) const alerts = reactive({ rows: [], total: null, loading: false, error: '', version: 0 }) const periods = [{ label: '今日', value: 'today' }, { label: '昨日', value: 'yesterday' }, { label: '近7天', value: 'last7' }, { label: '近30天', value: 'last30' }] -const cards = [{ key: 'todayRegister', label: '今日渠道玩家注册' }, { key: 'todayActive', label: '今日渠道活跃玩家' }, { key: 'todayPay', label: '今日充值金额', money: true }, { key: 'monthPay', label: '本月充值金额', money: true }, { key: 'activePromote', label: '活跃渠道数' }, { key: 'newPromote', label: '新增渠道数' }] +const cards = [{ key: 'todayRegister', label: '玩家注册' }, { key: 'todayActive', label: '活跃玩家' }, { key: 'todayPay', label: '充值金额', money: true }, { key: 'effectivePromote', label: '有效端' }, { key: 'activePromote', label: '活跃渠道数' }, { key: 'newPromote', label: '新增渠道数' }, { key: 'lockedPromote', label: '锁定渠道', event: true }, { key: 'terminatedPromote', label: '解除合作渠道', event: true }] const panels = reactive([ { key: 'register', endpoint: 'registerTop', title: '渠道注册 TOP10', period: 'today' }, { key: 'payHigh', endpoint: 'payRank', title: '渠道充值 TOP10', period: 'today', order: 'desc' }, @@ -57,21 +65,22 @@ const panels = reactive([ ].map(panel => ({ ...panel, rows: [], loading: false, error: '', version: 0 }))) let summaryVersion = 0 const count = value => value == null ? '未获取' : channelCount(String(value).split('.')[0]) -const metricValue = card => { const value = summary.value?.[card.key]?.value; if (value == null) return !sources.value.length ? '—' : summary.value?.available === false ? '未接入' : '未获取'; return card.money ? channelMoney(value) : count(value) } +const metricValue = card => { const value = summary.value?.[card.key]?.value; if (card.event && summary.value && value == null) return '未接入'; if (value == null) return !sources.value.length ? '—' : summary.value?.available === false ? '未接入' : '未获取'; return card.money ? channelMoney(value) : count(value) } const rateType = rate => rate == null || Number(rate) === 0 ? 'info' : Number(rate) > 0 ? 'success' : 'danger' const metricCompare = key => { const card = summary.value?.[key] - if (card?.value == null) return '—' + if (card?.value == null) return card?.compareLabel === '事件数据未接入' ? card.compareLabel : '—' if (card.changeRate == null) return `${card.compareLabel}:无可比数据` return `${card.compareLabel} ${Number(card.changeRate) > 0 ? '+' : ''}${card.changeRate}%` } const rankValue = (panel, value) => panel.key === 'register' ? count(value) : panel.key === 'unlogin' ? `${count(value)} 天` : channelMoney(value) const rankingRows = panel => panel.rows.map(row => ({ rowKey: `${row.sourceDb}:${row.promoteId}`, rank: row.rank, objectName: row.promoteName, sourceDbLabel: row.sourceDbLabel, metricValue: row.value })) const loadSummary = async () => { + if (overviewPeriod.value === 'custom' && (!overviewDates.value?.[0] || !overviewDates.value?.[1] || overviewDates.value[0] > overviewDates.value[1])) { ElMessage.warning('请选择正确的起止日期'); return } const current = ++summaryVersion; summary.value = null; summaryError.value = '' if (!sources.value.length) { summaryLoading.value = false; return } summaryLoading.value = true - try { const res = await getWorkbenchSummary({ sourceDbList: sources.value.join(',') }); if (current !== summaryVersion) return; if (res.code === 0) summary.value = res.data; else summaryError.value = res.msg || '商务指标读取失败' } + try { const res = await getDashboardOverview({ sourceDbList: sources.value.join(','), period: overviewPeriod.value, begin: overviewDates.value?.[0], end: overviewDates.value?.[1] }); if (current !== summaryVersion) return; if (res.code === 0) summary.value = { ...res.data, ...res.data.cards }; else summaryError.value = res.msg || '商务指标读取失败' } catch { if (current === summaryVersion) summaryError.value = '商务指标读取失败,请重试' } finally { if (current === summaryVersion) summaryLoading.value = false } } const loadRank = async panel => { @@ -100,6 +109,7 @@ onMounted(refresh) diff --git a/web/src/view/sw/BusinessPerformance.vue b/web/src/view/sw/BusinessPerformance.vue new file mode 100644 index 0000000..2e777c2 --- /dev/null +++ b/web/src/view/sw/BusinessPerformance.vue @@ -0,0 +1,128 @@ + + + diff --git a/web/src/view/sw/ChannelList.vue b/web/src/view/sw/ChannelList.vue index bd210a4..b12669e 100644 --- a/web/src/view/sw/ChannelList.vue +++ b/web/src/view/sw/ChannelList.vue @@ -8,6 +8,7 @@ + 搜索
@@ -26,6 +27,9 @@ + + + @@ -44,6 +48,7 @@ import { useUserStore } from '@/pinia/modules/user' import { useBtnAuth } from '@/utils/btnAuth' import { listChannels } from '@/api/channel' import { channelCount, channelMoney } from './channel-display' +import { dateText } from './performance-display' import AddChannelDrawer from './components/AddChannelDrawer.vue' import ChannelGameDrawer from './components/ChannelGameDrawer.vue' import ChannelProfileDrawer from './components/ChannelProfileDrawer.vue' @@ -56,7 +61,7 @@ const platforms = { lx: '龙翔', hw: '汇玩', yq: '游趣' } const levels = { 1: '一级渠道', 2: '二级渠道', 3: '三级渠道' } const statuses = { 0: '正常', '-1': '已锁定', '-2': '解除合作' } const sources = ref(Object.keys(platforms)) -const query = reactive({ pageNum: 1, pageSize: 10, busierAccount: '', keyword: '', promoteLevel: undefined, registerType: undefined, lockStatus: undefined }) +const query = reactive({ pageNum: 1, pageSize: 10, busierAccount: '', keyword: '', promoteLevel: undefined, registerType: undefined, lockStatus: undefined, effective: undefined }) const rows = ref([]) const total = ref(0) const available = ref(true) diff --git a/web/src/view/sw/OtherSettings.vue b/web/src/view/sw/OtherSettings.vue index dafb9ba..8cd39fc 100644 --- a/web/src/view/sw/OtherSettings.vue +++ b/web/src/view/sw/OtherSettings.vue @@ -85,12 +85,12 @@ const save = async () => { if (saving.value) return saving.value = true try { - await ElMessageBox.confirm(`确认保存有效端参数?达标金额 ${amountText.value},判定窗口 ${form.days} 天。`, '保存设置', { type: 'warning', confirmButtonText: '确认保存', cancelButtonText: '取消' }) + await ElMessageBox.confirm(`确认保存并按新规则重算有效端?达标金额 ${amountText.value},判定窗口 ${form.days} 天,历史统计也会随之更新。`, '保存设置', { type: 'warning', confirmButtonText: '确认保存', cancelButtonText: '取消' }) } catch { saving.value = false; return } try { const res = await saveOtherSettings({ amount: form.amount, days: form.days }) if (res.code !== 0) { error.value = res.msg || '保存失败,请重试'; return } - apply(res.data); error.value = ''; status.value = '参数已保存' + apply(res.data); error.value = ''; status.value = '参数已保存,有效端判定已更新' ElMessage.success('参数已保存') } catch { error.value = '保存失败,请重试' } finally { saving.value = false } } diff --git a/web/src/view/sw/components/BusinessManagement.vue b/web/src/view/sw/components/BusinessManagement.vue new file mode 100644 index 0000000..4688d28 --- /dev/null +++ b/web/src/view/sw/components/BusinessManagement.vue @@ -0,0 +1,137 @@ + + + diff --git a/web/src/view/sw/components/GoalDrawer.vue b/web/src/view/sw/components/GoalDrawer.vue new file mode 100644 index 0000000..090aefe --- /dev/null +++ b/web/src/view/sw/components/GoalDrawer.vue @@ -0,0 +1,51 @@ + + diff --git a/web/src/view/sw/components/MyGoalCard.vue b/web/src/view/sw/components/MyGoalCard.vue new file mode 100644 index 0000000..2498a66 --- /dev/null +++ b/web/src/view/sw/components/MyGoalCard.vue @@ -0,0 +1,29 @@ + + + diff --git a/web/src/view/sw/components/PerformanceDetailDrawer.vue b/web/src/view/sw/components/PerformanceDetailDrawer.vue new file mode 100644 index 0000000..5128bd5 --- /dev/null +++ b/web/src/view/sw/components/PerformanceDetailDrawer.vue @@ -0,0 +1,29 @@ + + diff --git a/web/src/view/sw/components/PerformanceScroll.vue b/web/src/view/sw/components/PerformanceScroll.vue new file mode 100644 index 0000000..6cc2af4 --- /dev/null +++ b/web/src/view/sw/components/PerformanceScroll.vue @@ -0,0 +1,35 @@ + + + diff --git a/web/src/view/sw/components/PerformanceTrend.vue b/web/src/view/sw/components/PerformanceTrend.vue new file mode 100644 index 0000000..1513cfb --- /dev/null +++ b/web/src/view/sw/components/PerformanceTrend.vue @@ -0,0 +1,42 @@ + + + diff --git a/web/src/view/sw/components/ReportAnalytics.vue b/web/src/view/sw/components/ReportAnalytics.vue index 7d3431b..b1ae936 100644 --- a/web/src/view/sw/components/ReportAnalytics.vue +++ b/web/src/view/sw/components/ReportAnalytics.vue @@ -28,6 +28,7 @@ +

合计:有效端 {{ result?.effectiveCount == null ? '未接入' : result.effectiveCount }} 个

diff --git a/web/src/view/sw/components/TeamSettings.vue b/web/src/view/sw/components/TeamSettings.vue new file mode 100644 index 0000000..5fc0c09 --- /dev/null +++ b/web/src/view/sw/components/TeamSettings.vue @@ -0,0 +1,65 @@ + + + diff --git a/web/src/view/sw/performance-display.js b/web/src/view/sw/performance-display.js new file mode 100644 index 0000000..162061d --- /dev/null +++ b/web/src/view/sw/performance-display.js @@ -0,0 +1,18 @@ +import { channelMoney, channelCount } from './channel-display.js' +import { compareAmounts } from './table-display.js' +export const employmentLabels = { employed: '在职', probation: '试用', departed: '离职' } +export const employmentTypes = { employed: 'success', probation: 'warning', departed: 'info' } +export const businessLevels = { 1: '一级商务', 2: '二级商务', 3: '三级商务' } +export const performanceMetrics = [{ key: 'open', label: '开端', goal: 'open' }, { key: 'effective', label: '有效端', goal: 'effective' }, { key: 'pay', label: '充值流水', goal: 'amount' }] +export const displayMember = member => member?.nickName || member?.userName || '—' +export const displayMetric = (key, value) => value == null ? '—' : key === 'pay' || key === 'amount' ? channelMoney(value) : channelCount(value) +export const displayTarget = (key, value) => value == null ? '—' : key === 'amount' ? channelMoney(value) : Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 2 }) +export const completionRate = (actual, target) => target == null || Number(target) <= 0 ? null : Number(actual || 0) / Number(target) * 100 +export const goalAchieved = (actual, target, key) => target != null && Number(target) > 0 && (key === 'pay' ? compareAmounts(actual, String(target)) >= 0 : Number(actual) >= target) +export const rateText = rate => rate == null ? '—' : `${rate.toFixed(1)}%` +export const progressWidth = rate => Math.max(0, Math.min(100, rate || 0)) +export const dateText = value => value == null ? '—' : new Date(Number(value) * 1000).toLocaleDateString('zh-CN').replaceAll('/', '-') +export const currentMonth = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` } +export const monthOffset = (month, offset) => { const [y, m] = month.split('-').map(Number), d = new Date(y, m - 1 + offset, 1); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` } +export const hasGoal = goal => goal && [goal.open, goal.effective, goal.amount].some(v => v != null) +export const effectiveRate = metrics => metrics?.open ? Number(metrics.effective) / Number(metrics.open) * 100 : null diff --git a/web/src/view/sw/performance-display.test.js b/web/src/view/sw/performance-display.test.js new file mode 100644 index 0000000..8958929 --- /dev/null +++ b/web/src/view/sw/performance-display.test.js @@ -0,0 +1,18 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { goalAchieved, completionRate, hasGoal, monthOffset } from './performance-display.js' + +test('goal attainment compares exact cents even when rounded percentages look complete', () => { + assert.equal(goalAchieved('9007199254740990.99', 9007199254740991, 'pay'), false) + assert.equal(goalAchieved('9007199254740991.00', 9007199254740991, 'pay'), true) + assert.equal(goalAchieved('100.00', 0, 'pay'), false) + assert.equal(goalAchieved(10, 10, 'open'), true) + assert.equal(completionRate(12, 10), 120) +}) + +test('empty and zero targets stay distinct and year boundaries retain the month', () => { + assert.equal(hasGoal({ open: null, effective: null, amount: null }), false) + assert.equal(hasGoal({ open: 0, effective: null, amount: null }), true) + assert.equal(monthOffset('2026-01', -1), '2025-12') + assert.equal(monthOffset('2026-12', 1), '2027-01') +})