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