From db11b6eb54fb59a62a8b1b52d0f496208536c586 Mon Sep 17 00:00:00 2001 From: Yvan <8574526@qq,com> Date: Thu, 5 Nov 2026 16:18:39 +0800 Subject: [PATCH] fix: align business pages and detail drawers with prototype --- internal/biz/business/channel.go | 45 ++++- internal/biz/business/channel_test.go | 13 +- internal/biz/business/management_test.go | 37 +++- internal/biz/business/performance.go | 21 ++- internal/data/business/business_names.go | 53 ++++++ internal/data/business/channel.go | 63 ++++++- internal/data/business/channel_sort.go | 167 ++++++++++++++++++ internal/data/business/channel_sort_test.go | 71 ++++++++ internal/data/business/channel_test.go | 2 +- internal/data/business/ledger.go | 4 +- internal/service/business/channel.go | 4 +- internal/service/business/channel_portrait.go | 4 + .../service/business/channel_portrait_test.go | 34 ++++ .../service/business/performance_excel.go | 2 +- internal/service/dto/channel_portrait.go | 4 + web/src/api/channel.js | 2 +- web/src/view/sw/BusinessPerformance.vue | 21 ++- web/src/view/sw/ChannelList.vue | 39 ++-- web/src/view/sw/ServerAnalysis.vue | 8 +- .../view/sw/components/BusinessBindings.vue | 4 +- .../view/sw/components/BusinessManagement.vue | 23 ++- .../sw/components/ChannelProfileDrawer.vue | 6 +- web/src/view/sw/components/GoalDrawer.vue | 2 +- .../sw/components/PerformanceDetailDrawer.vue | 33 +++- .../view/sw/components/PerformanceTrend.vue | 8 +- .../view/sw/components/ReportAnalytics.vue | 6 +- web/src/view/sw/performance-display.js | 5 + web/src/view/sw/performance-display.test.js | 9 +- 28 files changed, 603 insertions(+), 87 deletions(-) create mode 100644 internal/data/business/business_names.go create mode 100644 internal/data/business/channel_sort.go create mode 100644 internal/data/business/channel_sort_test.go create mode 100644 internal/service/business/channel_portrait_test.go diff --git a/internal/biz/business/channel.go b/internal/biz/business/channel.go index 8c24f5c..f1b489c 100644 --- a/internal/biz/business/channel.go +++ b/internal/biz/business/channel.go @@ -16,6 +16,7 @@ import ( var ( ErrChannelNotFound = errors.NotFound("SW_CHANNEL_NOT_FOUND", "渠道不存在") ErrChannelScope = errors.Forbidden("SW_CHANNEL_SCOPE", "无权查看该渠道") + ErrChannelCreateScope = errors.Forbidden("SW_CHANNEL_CREATE_SCOPE", "仅三级商务可添加渠道") ErrChannelInput = errors.BadRequest("SW_CHANNEL_INPUT", "渠道查询参数不正确") ErrChannelAccount = errors.BadRequest("SW_CHANNEL_ACCOUNT", "渠道账号需为中文/英文/数字,长度 4-30 位") ErrChannelPassword = errors.BadRequest("SW_CHANNEL_PASSWORD", "渠道密码长度需为 6-15 位") @@ -35,6 +36,7 @@ type Channel struct { ChannelKey Account, Nickname, ParentName, RealName string Phone string + BusinessPath []string SettlementDays *int Level, RegisterType, Status, LockStatus *int ParentID, TopPromoteID, BusierID uint64 @@ -47,6 +49,9 @@ type Channel struct { } func (c *Channel) BusinessDisplayName() string { + if len(c.BusinessPath) > 0 { + return strings.Join(c.BusinessPath, "→") + } account, user := strings.TrimSpace(c.BindingAccount), strings.TrimSpace(c.BindingUserName) if account == "" { return user @@ -56,6 +61,26 @@ func (c *Channel) BusinessDisplayName() string { } return account + "(" + user + ")" } + +func (c *Channel) ParentDisplayName() string { + if c.Level != nil && *c.Level == 1 { + if len(c.BusinessPath) > 0 { + return c.BusinessPath[len(c.BusinessPath)-1] + } + return c.BusinessDisplayName() + } + return c.ParentName +} + +func ChannelLockStatusLabel(status *int) string { + if status == nil || *status == 0 { + return "正常" + } + if *status == -2 { + return "解除合作" + } + return "锁定" +} func (c *Channel) IncludesStat(other *Channel, includeSub bool) bool { if c.SourceDB != other.SourceDB { return false @@ -73,6 +98,7 @@ func (c *Channel) IncludesStat(other *Channel, includeSub bool) bool { } type ChannelFilter struct { + IncludeSub, IDCardDedup bool Effective *bool Sources []string BusierUserID *uint @@ -179,6 +205,9 @@ func normalizeIDCard(raw string) string { } return value } + +// ChannelIdentity is shared by list metrics and storage-side metric ordering. +func ChannelIdentity(raw string) string { return normalizeIDCard(raw) } func (uc *ChannelUsecase) List(ctx context.Context, filter ChannelFilter, options ...ListOption) (*ChannelPage, error) { scope, err := uc.scope(ctx) if err != nil { @@ -189,14 +218,15 @@ func (uc *ChannelUsecase) List(ctx context.Context, filter ChannelFilter, option return nil, err } } - page, err := uc.repo.List(ctx, scope, filter, options...) - if err != nil || len(page.Rows) == 0 { - return page, err - } pref, err := uc.preferences.Current(ctx) if err != nil { return nil, err } + filter.IncludeSub, filter.IDCardDedup = pref.IncludeSubPromote, pref.IDCardDedup + page, err := uc.repo.List(ctx, scope, filter, options...) + if err != nil || len(page.Rows) == 0 { + return page, err + } statChannels := page.Rows if pref.IncludeSubPromote { statChannels, err = uc.repo.StatChannels(ctx, page.Rows) @@ -290,6 +320,13 @@ func (uc *ChannelUsecase) Create(ctx context.Context, input *ChannelCreate) (*Ch if input == nil { return nil, ErrChannelInput } + actor, accountOwner, err := uc.accounts.actor(ctx) + if err != nil { + return nil, err + } + if actor.AuthorityID != 888 && accountOwner.Level != 3 { + return nil, ErrChannelCreateScope + } account := strings.TrimSpace(input.Account) if !channelAccountPattern.MatchString(account) { return nil, ErrChannelAccount diff --git a/internal/biz/business/channel_test.go b/internal/biz/business/channel_test.go index 5b83b34..6f1be0b 100644 --- a/internal/biz/business/channel_test.go +++ b/internal/biz/business/channel_test.go @@ -110,7 +110,12 @@ func TestChannelLevelTwoScopeAndUnavailableMetrics(t *testing.T) { func TestChannelCreateOwnsHashAndSettlementDefaults(t *testing.T) { uc, r, _, writer, encoder := channelFixture() input := &ChannelCreate{SourceDB: " LX ", Account: " 中文渠道 ", Password: " pass123 ", BusierID: 14, RegisterType: 1} - result, err := uc.Create(accountContext(8, 902), input) + for _, actor := range [][2]uint{{7, 901}, {8, 902}} { + if _, err := uc.Create(accountContext(actor[0], actor[1]), input); err != ErrChannelCreateScope { + t.Fatal("一级和二级商务不能添加渠道") + } + } + result, err := uc.Create(accountContext(9, 903), input) if err != nil || result.ID != 123 || result.SourceDB != "lx" { t.Fatalf("创建失败: %+v %v", result, err) } @@ -118,17 +123,17 @@ func TestChannelCreateOwnsHashAndSettlementDefaults(t *testing.T) { t.Fatalf("创建参数或结算周期错误: %+v", writer.input) } input.BusierID = 999 - if _, err := uc.Create(accountContext(8, 902), input); err != ErrChannelBinding { + if _, err := uc.Create(accountContext(9, 903), input); err != ErrChannelBinding { t.Fatal("选择了范围外的绑定") } input.BusierID = 14 input.Account = "bad_underscore" - if _, err := uc.Create(accountContext(8, 902), input); err != ErrChannelAccount { + if _, err := uc.Create(accountContext(9, 903), input); err != ErrChannelAccount { t.Fatal("渠道账号字符限制丢失") } input.Account = "valid123" input.Password = "12345" - if _, err := uc.Create(accountContext(8, 902), input); err != ErrChannelPassword { + if _, err := uc.Create(accountContext(9, 903), input); err != ErrChannelPassword { t.Fatal("密码长度未校验") } } diff --git a/internal/biz/business/management_test.go b/internal/biz/business/management_test.go index b3084ae..c504607 100644 --- a/internal/biz/business/management_test.go +++ b/internal/biz/business/management_test.go @@ -172,7 +172,7 @@ func TestPerformanceRateSortKeepsUndefinedRatesLast(t *testing.T) { } } -func TestPerformanceMatrixTotalsFollowDisplayedMemberUnion(t *testing.T) { +func TestPerformanceMatrixTotalsKeepVisibleScopeWhenRowsAreFiltered(t *testing.T) { _, uc, _, teams, _ := managementFixture() teams.catalog.Groups[0].MemberIDs = []uint{8, 9, 10} ctx := accountContext(7, 901) @@ -181,17 +181,44 @@ func TestPerformanceMatrixTotalsFollowDisplayedMemberUnion(t *testing.T) { 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 len(page.Rows) != 1 || page.MatrixTotal != page.Total || page.MatrixTotal.Open != 2 || page.Groups[0].UniqueTotal.Open != 1 { + t.Fatalf("matrix total must retain the full visible scope, with a filtered group subtotal: %+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) + if err != nil || len(page.Rows) != 0 || page.MatrixTotal != page.Total || page.Total.Open != 2 { + t.Fatalf("empty displayed rows must not erase visible-scope totals: %+v %v", page, err) } + for period, total := range page.Trend { + if page.MatrixTrend[period] != total { + t.Fatalf("period total changed with filtering: %s", period) + } + } +} + +func TestBusinessManagementIncludesCurrentGoalWithoutCurrentPerformance(t *testing.T) { + _, uc, _, _, _ := managementFixture() + amount := int64(2000000) + uc.goals.(*managementGoalFake).values = []*PerformanceGoal{{Kind: "person", TargetID: 9, Month: "2026-11", Amount: &amount}} + page, err := uc.Query(accountContext(7, 901), PerformanceFilter{ForManagement: true}) + if err != nil { + t.Fatal(err) + } + if page.End != "2026-10-31" { + t.Fatal("business performance must still stop at the last completed month") + } + for _, row := range page.Rows { + if row.Member.ID == 9 { + if goal := row.Goals["2026-11"]; goal == nil || goal.Amount == nil || *goal.Amount != amount { + t.Fatal("the business list must include this month's configured goal") + } + return + } + } + t.Fatal("visible member was lost") } func TestPerformanceDetailSelectionPreservesContributionsAndScope(t *testing.T) { diff --git a/internal/biz/business/performance.go b/internal/biz/business/performance.go index edf6288..f9e7c32 100644 --- a/internal/biz/business/performance.go +++ b/internal/biz/business/performance.go @@ -173,7 +173,6 @@ func (uc *PerformanceUsecase) query(ctx context.Context, f PerformanceFilter, ow 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)) @@ -209,9 +208,8 @@ func (uc *PerformanceUsecase) query(ctx context.Context, f PerformanceFilter, ow } } type attribution struct { - members []uint - groups []uint64 - inMatrix bool + members []uint + groups []uint64 } owners := map[ChannelKey]attribution{} forOwners := func(ownerIDs []uint) attribution { @@ -227,7 +225,6 @@ func (uc *PerformanceUsecase) query(ctx context.Context, f PerformanceFilter, ow 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) @@ -262,9 +259,6 @@ func (uc *PerformanceUsecase) query(ctx context.Context, f PerformanceFilter, ow 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) } @@ -309,7 +303,12 @@ func (uc *PerformanceUsecase) query(ctx context.Context, f PerformanceFilter, ow } add(a, periodKey(cash.Date), "pay", pay) } - goals, err := uc.goals.List(ctx, begin.Format("2006-01"), end.Format("2006-01")) + goalEnd := end.Format("2006-01") + if f.ForManagement { + // The business list shows historical performance and this month's goal. + goalEnd = snapshotTime.Format("2006-01") + } + goals, err := uc.goals.List(ctx, begin.Format("2006-01"), goalEnd) if err != nil { return nil, err } @@ -362,9 +361,9 @@ func (uc *PerformanceUsecase) query(ctx context.Context, f PerformanceFilter, ow } } out.Total = all.total.metrics() - out.MatrixTotal = matrix.total.metrics() + out.MatrixTotal = out.Total out.MatrixRate = performanceRate(out.MatrixTotal) - out.MatrixTrend = matrix.values() + out.MatrixTrend = all.values() out.Prior = prior.metrics() out.Rate = performanceRate(out.Total) out.Trend = all.values() diff --git a/internal/data/business/business_names.go b/internal/data/business/business_names.go new file mode 100644 index 0000000..718d26b --- /dev/null +++ b/internal/data/business/business_names.go @@ -0,0 +1,53 @@ +package business + +import ( + "strconv" + "strings" + + "gorm.io/gorm" + "kra/internal/biz/business" +) + +func businessDisplayPaths(db *gorm.DB, owners map[uint]string) (map[uint][]string, error) { + ids := map[uint]bool{} + paths := map[uint][]uint{} + for owner, path := range owners { + seen := map[uint]bool{} + for _, part := range strings.Split(path, ",") { + id, err := strconv.ParseUint(strings.TrimSpace(part), 10, 32) + if err == nil && id > 0 && uint(id) != owner && !seen[uint(id)] { + paths[owner] = append(paths[owner], uint(id)) + ids[uint(id)], seen[uint(id)] = true, true + } + } + paths[owner] = append(paths[owner], owner) + ids[owner] = true + } + keys := make([]uint, 0, len(ids)) + for id := range ids { + keys = append(keys, id) + } + names := map[uint]string{} + for start := 0; start < len(keys); start += 300 { + var rows []accountPO + if err := db.Select("id,username,nick_name").Where("id IN ? AND level>0", keys[start:min(start+300, len(keys))]).Find(&rows).Error; err != nil { + return nil, business.ErrStorage + } + for _, row := range rows { + name := strings.TrimSpace(row.NickName) + if name == "" { + name = row.Username + } + names[row.ID] = name + } + } + result := map[uint][]string{} + for owner, ids := range paths { + for _, id := range ids { + if name := names[id]; name != "" { + result[owner] = append(result[owner], name) + } + } + } + return result, nil +} diff --git a/internal/data/business/channel.go b/internal/data/business/channel.go index 83c61f7..f884fe7 100644 --- a/internal/data/business/channel.go +++ b/internal/data/business/channel.go @@ -3,6 +3,7 @@ package business import ( "context" "errors" + "sort" "strings" "time" @@ -140,11 +141,34 @@ func (r *channelRepo) List(ctx context.Context, scope business.UserScope, filter if filter.SortField == "createdAt" && filter.Ascending { order = "p.create_time ASC,p.id ASC,p.source_db ASC" } - keysQuery := q.Select("p.id,p.source_db").Order(order).Offset(o.Offset).Limit(o.Limit) - 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.*,e.qualified_at").Order(order).Scan(&pos).Error; err != nil { + var pageQuery *gorm.DB + var metricKeys []business.ChannelKey + metricSort := filter.SortField == "playerCount" || filter.SortField == "totalPayAmount" + if metricSort { + metricKeys, err = channelMetricPage(db, q, filter, o) + if err != nil { + return nil, err + } + if len(metricKeys) == 0 { + return page, nil + } + pageQuery = joinEffectiveness(db.Table("tab_promote p").Where("(p.source_db,p.id) IN ?", channelPairs(metricKeys))) + } else { + keysQuery := q.Select("p.id,p.source_db").Order(order).Offset(o.Offset).Limit(o.Limit) + 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)).Order(order) + } + if err := pageQuery.Select("p.*,e.qualified_at").Scan(&pos).Error; err != nil { return nil, business.ErrStorage } + if metricSort { + ranks := map[business.ChannelKey]int{} + for i, key := range metricKeys { + ranks[key] = i + } + sort.Slice(pos, func(i, j int) bool { + return ranks[business.ChannelKey{SourceDB: pos[i].SourceDB, ID: pos[i].ID}] < ranks[business.ChannelKey{SourceDB: pos[j].SourceDB, ID: pos[j].ID}] + }) + } if len(pos) == 0 { return page, nil } @@ -157,10 +181,19 @@ func (r *channelRepo) List(ctx context.Context, scope business.UserScope, filter PlatformBusierID uint64 UserID uint PlatformBusierAccount, UserName string + IDPath string } - if err := channelBindingQuery(db, scope, filter).Select("b.source_db,b.platform_busier_id,b.user_id,b.platform_busier_account,u.username AS user_name").Where("(b.source_db,b.platform_busier_id) IN ?", pairs).Order("b.user_id").Scan(&bound).Error; err != nil { + if err := channelBindingQuery(db, scope, filter).Select("b.source_db,b.platform_busier_id,b.user_id,b.platform_busier_account,u.username AS user_name,u.id_path").Where("(b.source_db,b.platform_busier_id) IN ?", pairs).Order("b.user_id").Scan(&bound).Error; err != nil { return nil, business.ErrStorage } + ownerPaths := map[uint]string{} + for _, b := range bound { + ownerPaths[b.UserID] = b.IDPath + } + paths, err := businessDisplayPaths(db, ownerPaths) + if err != nil { + return nil, err + } byKey := map[business.PlatformKey]int{} for i, b := range bound { key := business.PlatformKey{SourceDB: b.SourceDB, BusierID: b.PlatformBusierID} @@ -175,6 +208,7 @@ func (r *channelRepo) List(ctx context.Context, scope business.UserScope, filter c.BusierUserID = &b.UserID c.BindingAccount = b.PlatformBusierAccount c.BindingUserName = b.UserName + c.BusinessPath = paths[b.UserID] } page.Rows = append(page.Rows, c) } @@ -205,7 +239,26 @@ func (r *channelRepo) Find(ctx context.Context, scope business.UserScope, key bu return nil, business.ErrChannelScope } } - return toBizChannel(po), nil + c := toBizChannel(po) + var bindings []struct { + UserID uint + PlatformBusierAccount, UserName, IDPath string + } + if err := channelBindingQuery(db, scope, business.ChannelFilter{Sources: []string{key.SourceDB}}). + Where("b.platform_busier_id = ?", po.BusierID).Order("b.user_id").Limit(1). + Select("b.user_id,b.platform_busier_account,u.username AS user_name,u.id_path").Scan(&bindings).Error; err != nil { + return nil, business.ErrStorage + } + if len(bindings) > 0 { + b := bindings[0] + paths, err := businessDisplayPaths(db, map[uint]string{b.UserID: b.IDPath}) + if err != nil { + return nil, err + } + c.BusierUserID, c.BindingAccount, c.BindingUserName = &b.UserID, b.PlatformBusierAccount, b.UserName + c.BusinessPath = paths[b.UserID] + } + return c, nil } func channelPairs(keys []business.ChannelKey) [][]any { pairs := make([][]any, 0, len(keys)) diff --git a/internal/data/business/channel_sort.go b/internal/data/business/channel_sort.go new file mode 100644 index 0000000..c071a8b --- /dev/null +++ b/internal/data/business/channel_sort.go @@ -0,0 +1,167 @@ +package business + +import ( + "database/sql" + "math/big" + "sort" + + "gorm.io/gorm" + "kra/internal/biz/business" +) + +// Build the same per-channel membership as Channel.IncludesStat. UNION removes +// descendants reached through both parent_id and top_promote_id. +func channelMetricMembership(db, roots *gorm.DB, includeSub bool) *gorm.DB { + self := roots.Session(&gorm.Session{}).Select("p.source_db,p.id AS root_id,p.id AS leaf_id") + if !includeSub { + return db.Table("(?) cm", self) + } + direct := roots.Session(&gorm.Session{}).Select("p.source_db,p.id AS root_id,leaf.id AS leaf_id"). + Joins("JOIN tab_promote leaf ON leaf.source_db=p.source_db AND leaf.parent_id=p.id"). + Where("p.promote_level<3 AND (p.promote_level<>2 OR leaf.promote_level=3)") + top := roots.Session(&gorm.Session{}).Select("p.source_db,p.id AS root_id,leaf.id AS leaf_id"). + Joins("JOIN tab_promote leaf ON leaf.source_db=p.source_db AND leaf.top_promote_id=p.id"). + Where("p.promote_level<3 AND p.promote_level<>2") + return db.Table("(? UNION ? UNION ?) cm", self, direct, top) +} + +// Only keys and scores for visible rows are retained. Identity rows are streamed +// one channel at a time, using the same normalization as the displayed count. +func channelMetricPage(db, roots *gorm.DB, f business.ChannelFilter, o business.ListOptions) ([]business.ChannelKey, error) { + var channels []channelPO + if err := roots.Session(&gorm.Session{}).Select("p.id,p.source_db,p.create_time").Scan(&channels).Error; err != nil { + return nil, business.ErrStorage + } + if len(channels) == 0 { + return nil, nil + } + table := "tab_user" + if f.SortField == "totalPayAmount" { + table = "tab_spend" + } + available, err := businessTableAvailable(db, table) + if err != nil { + return nil, err + } + if !available { + return nil, business.ErrChannelUnavailable + } + scores := map[business.ChannelKey]*big.Rat{} + q := channelMetricMembership(db, roots, f.IncludeSub) + // MySQL 5.7 may choose the narrow pay_status index and then fetch millions + // of full records. Prefer the existing covering index for this full scan. + metricTable := table + index := "idx_sw_user_report_identity" + if table == "tab_spend" { + index = "idx_sw_spend_promote_summary" + } + if db.Dialector.Name() == "mysql" && db.Migrator().HasIndex(table, index) { + metricTable += " FORCE INDEX (" + index + ")" + } + leaves := db.Table(metricTable) + if len(f.Sources) > 0 { + leaves = leaves.Where("source_db IN ?", f.Sources) + } + if f.SortField == "totalPayAmount" { + leaves = leaves.Where("pay_status=1") + } else { + leaves = leaves.Where("puid=0 OR puid IS NULL") + } + if f.SortField == "playerCount" && f.IDCardDedup { + join := "STRAIGHT_JOIN" + if db.Dialector.Name() == "sqlite" { + join = "JOIN" + } + // Stream the covering user index once, then look up its channel roots. + // Grouping raw identity strings first creates a large disk temporary table + // and still cannot apply the domain's exact whitespace/X normalization. + leaves = leaves.Select("source_db,promote_id,idcard") + members := q.Select("cm.source_db,cm.root_id,cm.leaf_id") + rows, err := db.Table("(?) u", leaves).Joins(join+" (?) cm ON cm.source_db=u.source_db AND cm.leaf_id=u.promote_id", members). + Select("cm.source_db,cm.root_id,u.idcard").Order("cm.source_db,cm.root_id").Rows() + if err != nil { + return nil, business.ErrStorage + } + defer rows.Close() + var previous business.ChannelKey + identities := map[string]struct{}{} + flush := func() { scores[previous] = big.NewRat(int64(len(identities)), 1) } + for rows.Next() { + var key business.ChannelKey + var card sql.NullString + if err := rows.Scan(&key.SourceDB, &key.ID, &card); err != nil { + return nil, business.ErrStorage + } + if key != previous { + flush() + identities = map[string]struct{}{} + previous = key + } + if identity := business.ChannelIdentity(card.String); identity != "" { + identities[identity] = struct{}{} + } + } + if err := rows.Err(); err != nil { + return nil, business.ErrStorage + } + flush() + } else { + var values []struct { + SourceDB string + RootID uint64 + Score string + } + if f.SortField == "totalPayAmount" { + leaves = leaves.Select("source_db,promote_id,COALESCE(SUM(pay_amount),0) AS score") + } else { + leaves = leaves.Select("source_db,promote_id,COUNT(*) AS score") + } + leaves = leaves.Group("source_db,promote_id") + q = q.Joins("JOIN (?) leaf ON leaf.source_db=cm.source_db AND leaf.promote_id=cm.leaf_id", leaves). + Select("cm.source_db,cm.root_id,SUM(leaf.score) AS score") + if err := q.Group("cm.source_db,cm.root_id").Scan(&values).Error; err != nil { + return nil, business.ErrStorage + } + for _, value := range values { + score, ok := new(big.Rat).SetString(value.Score) + if !ok { + return nil, business.ErrStorage + } + scores[business.ChannelKey{SourceDB: value.SourceDB, ID: value.RootID}] = score + } + } + zero := new(big.Rat) + score := func(c channelPO) *big.Rat { + if n := scores[business.ChannelKey{SourceDB: c.SourceDB, ID: c.ID}]; n != nil { + return n + } + return zero + } + sort.Slice(channels, func(i, j int) bool { + a, b := channels[i], channels[j] + if compared := score(a).Cmp(score(b)); compared != 0 { + return (compared < 0) == f.Ascending + } + if a.CreateTime == nil || b.CreateTime == nil { + if (a.CreateTime == nil) != (b.CreateTime == nil) { + return a.CreateTime != nil + } + } else if *a.CreateTime != *b.CreateTime { + return *a.CreateTime > *b.CreateTime + } + if a.ID != b.ID { + return a.ID > b.ID + } + return a.SourceDB < b.SourceDB + }) + begin := min(max(0, o.Offset), len(channels)) + end := len(channels) + if o.Limit > 0 && o.Limit < end-begin { + end = begin + o.Limit + } + keys := make([]business.ChannelKey, 0, end-begin) + for _, row := range channels[begin:end] { + keys = append(keys, business.ChannelKey{SourceDB: row.SourceDB, ID: row.ID}) + } + return keys, nil +} diff --git a/internal/data/business/channel_sort_test.go b/internal/data/business/channel_sort_test.go new file mode 100644 index 0000000..a631cc3 --- /dev/null +++ b/internal/data/business/channel_sort_test.go @@ -0,0 +1,71 @@ +package business + +import ( + "context" + "reflect" + "testing" + + "kra/internal/biz/business" +) + +func TestChannelMetricSortingUsesFullScopeAndPreferencesBeforePagination(t *testing.T) { + _, repo := prepareChannels(t) + scope := business.UserScope{SelfID: 9, OnlySelf: true} + for _, sample := range []struct { + field string + include, dedup bool + want []uint64 + }{ + {"totalPayAmount", true, false, []uint64{101, 100, 103, 102}}, + {"totalPayAmount", false, true, []uint64{103, 102, 101, 100}}, + {"playerCount", true, false, []uint64{101, 100, 103, 102}}, + {"playerCount", true, true, []uint64{101, 103, 100, 102}}, + {"playerCount", false, true, []uint64{103, 101, 100, 102}}, + } { + t.Run(sample.field+"/"+map[bool]string{true: "sub", false: "self"}[sample.include]+"/"+map[bool]string{true: "dedup", false: "accounts"}[sample.dedup], func(t *testing.T) { + filter := business.ChannelFilter{SortField: sample.field, IncludeSub: sample.include, IDCardDedup: sample.dedup} + var got []uint64 + for offset := range sample.want { + page, err := repo.List(context.Background(), scope, filter, business.ListOffset(offset), business.ListLimit(1)) + if err != nil || page.Total != 4 || len(page.Rows) != 1 || page.StatusCounts.Normal != 4 { + t.Fatalf("unexpected page at %d: %+v %v", offset, page, err) + } + if page.Rows[0].SourceDB != "lx" { + t.Fatal("metric sorting leaked another business or platform") + } + got = append(got, page.Rows[0].ID) + } + if !reflect.DeepEqual(got, sample.want) { + t.Fatalf("want %v, got %v", sample.want, got) + } + filter.Ascending = true + filter.Keyword = "grandchild" + page, err := repo.List(context.Background(), scope, filter, business.ListLimit(1)) + if err != nil || page.Total != 1 || len(page.Rows) != 1 || page.Rows[0].ID != 102 { + t.Fatalf("metric sorting lost the name filter: %+v %v", page, err) + } + }) + } +} + +func TestChannelMetricSortRetainsZeroRowsAndExactIdentityNormalization(t *testing.T) { + db, repo := prepareChannels(t) + if err := db.Exec("INSERT INTO tab_promote(id,source_db,account,promote_level,parent_id,top_promote_id,busier_id) VALUES(200,'lx','zero',1,0,0,14)").Error; err != nil { + t.Fatal(err) + } + if err := db.Exec("INSERT INTO tab_user VALUES('lx',1000,100,0,?),('lx',1001,100,0,?)", "\tabcX \r", "\t \r").Error; err != nil { + t.Fatal(err) + } + filter := business.ChannelFilter{SortField: "playerCount", IncludeSub: true, IDCardDedup: true, Ascending: true} + page, err := repo.List(context.Background(), business.UserScope{SelfID: 9, OnlySelf: true}, filter) + if err != nil { + t.Fatal(err) + } + var got []uint64 + for _, row := range page.Rows { + got = append(got, row.ID) + } + if !reflect.DeepEqual(got, []uint64{200, 102, 103, 100, 101}) { + t.Fatalf("zero rows or identity normalization differ from displayed values: %v", got) + } +} diff --git a/internal/data/business/channel_test.go b/internal/data/business/channel_test.go index 1b63485..d0af428 100644 --- a/internal/data/business/channel_test.go +++ b/internal/data/business/channel_test.go @@ -112,7 +112,7 @@ func TestChannelListMatchesBindingsAndLiteralBusinessKeyword(t *testing.T) { t.Fatalf("指定商务应只取本人绑定: %+v %v", page, err) } for _, row := range page.Rows { - if row.SourceDB != "lx" || row.BusierUserID == nil || *row.BusierUserID != 9 || row.BusinessDisplayName() != "lx-business(leaf)" { + if row.SourceDB != "lx" || row.BusierUserID == nil || *row.BusierUserID != 9 || row.BusinessDisplayName() != "parent→branch→leaf" { t.Fatalf("归属展示错误: %+v", row) } } diff --git a/internal/data/business/ledger.go b/internal/data/business/ledger.go index bbd8378..a986138 100644 --- a/internal/data/business/ledger.go +++ b/internal/data/business/ledger.go @@ -370,7 +370,7 @@ func fillLedgerNames(db *gorm.DB, scope business.LedgerScope, filter business.Le names := map[business.PlatformKey]bindName{} for start := 0; start < len(pairs); start += 300 { var rows []bindName - q := channelBindingQuery(db, scope.Viewer, business.ChannelFilter{}).Select("b.source_db,b.platform_busier_id,b.platform_busier_account,b.user_id,u.username,u.id_path").Where("(b.source_db,b.platform_busier_id) IN ?", portraitBindingPairs(pairs[start:min(start+300, len(pairs))])).Order("b.user_id") + q := channelBindingQuery(db, scope.Viewer, business.ChannelFilter{}).Select("b.source_db,b.platform_busier_id,b.platform_busier_account,b.user_id,COALESCE(NULLIF(u.nick_name,''),u.username) AS username,u.id_path").Where("(b.source_db,b.platform_busier_id) IN ?", portraitBindingPairs(pairs[start:min(start+300, len(pairs))])).Order("b.user_id") if err := q.Scan(&rows).Error; err != nil { return business.ErrStorage } @@ -399,7 +399,7 @@ func fillLedgerNames(db *gorm.DB, scope business.LedgerScope, filter business.Le ancestors := map[uint]string{} for start := 0; start < len(ids); start += 300 { var rows []accountPO - if err := db.Select("id,username").Where("id IN ? AND level > 0", ids[start:min(start+300, len(ids))]).Find(&rows).Error; err != nil { + if err := db.Select("id,COALESCE(NULLIF(nick_name,''),username) AS username").Where("id IN ? AND level > 0", ids[start:min(start+300, len(ids))]).Find(&rows).Error; err != nil { return business.ErrStorage } for _, row := range rows { diff --git a/internal/service/business/channel.go b/internal/service/business/channel.go index 38b24ae..3a04e18 100644 --- a/internal/service/business/channel.go +++ b/internal/service/business/channel.go @@ -45,7 +45,7 @@ func (s *ChannelService) List(ctx context.Context, req *dto.ChannelQuery) (*dto. 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} filter.Phone, filter.SettlementDays = strings.TrimSpace(req.Phone), req.SettlementDays filter.SortField, filter.Ascending = req.SortField, req.SortOrder == "asc" - if filter.SortField != "" && filter.SortField != "createdAt" || filter.SettlementDays != nil && *filter.SettlementDays < 1 { + if filter.SortField != "" && filter.SortField != "createdAt" && filter.SortField != "playerCount" && filter.SortField != "totalPayAmount" || filter.SettlementDays != nil && *filter.SettlementDays < 1 { return nil, business.ErrChannelInput } for _, pair := range []struct { @@ -70,7 +70,7 @@ func (s *ChannelService) List(ctx context.Context, req *dto.ChannelQuery) (*dto. out.StatusCounts = &dto.ChannelStatusCounts{Normal: result.StatusCounts.Normal, Locked: result.StatusCounts.Locked, Terminated: result.StatusCounts.Terminated} } 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, Effective: c.QualifiedAt != nil, QualifiedAt: c.QualifiedAt, CreatedAt: c.CreatedUnix, LastLoginAt: c.LastLoginUnix, Phone: c.Phone, SettlementDays: c.SettlementDays}) + 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.ParentDisplayName(), 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, LastLoginAt: c.LastLoginUnix, Phone: c.Phone, SettlementDays: c.SettlementDays}) } return out, nil } diff --git a/internal/service/business/channel_portrait.go b/internal/service/business/channel_portrait.go index dc7d75d..7d1061d 100644 --- a/internal/service/business/channel_portrait.go +++ b/internal/service/business/channel_portrait.go @@ -28,7 +28,11 @@ func (s *ChannelPortraitService) Portrait(ctx context.Context, req *dto.ChannelP basic.ParentPromoteID = &parent.ID basic.ParentPromoteName = &parent.Account basic.ParentPromoteAccount = &parent.Account + } else if parent := c.ParentDisplayName(); parent != "" { + basic.ParentPromoteName = &parent } + basic.Phone, basic.SettlementDays = c.Phone, c.SettlementDays + basic.LockStatus, basic.LockStatusLabel = c.LockStatus, business.ChannelLockStatusLabel(c.LockStatus) out := &dto.ChannelPortraitResponse{Basic: basic, Period: p.Period, Available: p.Available, UnavailableReason: p.UnavailableReason, RegisterTrend: []*dto.PortraitTrend{}, PayTrend: []*dto.PortraitTrend{}, GameDistribution: []*dto.ChannelGameResponse{}, PayTiers: []*dto.ChannelPayTier{}} if m := p.Metrics; m != nil { out.Metrics = &dto.ChannelPortraitMetrics{Register: portraitMetricResponse(m.Register), PayAmount: portraitMetricResponse(m.PayAmount), PayRate: portraitMetricResponse(m.PayRate), Active: portraitMetricResponse(m.Active)} diff --git a/internal/service/business/channel_portrait_test.go b/internal/service/business/channel_portrait_test.go new file mode 100644 index 0000000..8c6d6c8 --- /dev/null +++ b/internal/service/business/channel_portrait_test.go @@ -0,0 +1,34 @@ +package business + +import ( + "context" + "testing" + + "kra/internal/biz/business" + "kra/internal/service/dto" +) + +type channelPortraitTransportFake struct{ result *business.ChannelPortrait } + +func (f channelPortraitTransportFake) Portrait(context.Context, business.ChannelKey, string) (*business.ChannelPortrait, error) { + return f.result, nil +} + +func TestChannelPortraitKeepsContactAndCooperationStatusSeparateFromAudit(t *testing.T) { + level, auditStatus, days := 1, 1, 7 + for _, sample := range []struct { + status int + label string + }{{0, "正常"}, {-1, "锁定"}, {-2, "解除合作"}} { + channel := &business.Channel{ChannelKey: business.ChannelKey{SourceDB: "lx", ID: 100}, Account: "渠道账号", Level: &level, Status: &auditStatus, LockStatus: &sample.status, Phone: "13812345678", SettlementDays: &days, BusinessPath: []string{"一级商务", "所属商务"}} + service := &ChannelPortraitService{uc: channelPortraitTransportFake{&business.ChannelPortrait{Basic: &business.ChannelPortraitBasic{Channel: channel}}}} + result, err := service.Portrait(context.Background(), &dto.ChannelPortraitQuery{SourceDB: "lx", PromoteID: 100}) + if err != nil { + t.Fatal(err) + } + b := result.Basic + if b.LockStatusLabel != sample.label || b.StatusLabel != "正常" || b.Phone != channel.Phone || b.SettlementDays == nil || *b.SettlementDays != 7 || b.BusierDisplayName != "一级商务→所属商务" || b.ParentPromoteName == nil || *b.ParentPromoteName != "所属商务" { + t.Fatalf("portrait metadata or status mismatch: %+v", b) + } + } +} diff --git a/internal/service/business/performance_excel.go b/internal/service/business/performance_excel.go index d64484e..5db8db4 100644 --- a/internal/service/business/performance_excel.go +++ b/internal/service/business/performance_excel.go @@ -62,7 +62,7 @@ func (s *BusinessPerformanceService) Export(ctx context.Context, req *dto.Perfor } } } - appendRow("筛选结果去重合计", "上下级不重复计算", "", "", data.MatrixTotal, data.MatrixRate, data.MatrixTrend) + appendRow("可见范围合计", "不随人员筛选变化,上下级不重复计算", "", "", data.MatrixTotal, data.MatrixRate, data.MatrixTrend) return ledgerWorkbook("商务绩效统计", headers, rows) } diff --git a/internal/service/dto/channel_portrait.go b/internal/service/dto/channel_portrait.go index f59f42c..bae5153 100644 --- a/internal/service/dto/channel_portrait.go +++ b/internal/service/dto/channel_portrait.go @@ -12,6 +12,10 @@ type ChannelPortraitBasic struct { Account string `json:"account"` Nickname string `json:"nickname"` RealName string `json:"realName"` + Phone string `json:"phone"` + SettlementDays *int `json:"settlementDays"` + LockStatus *int `json:"lockStatus"` + LockStatusLabel string `json:"lockStatusLabel"` PromoteLevel *int `json:"promoteLevel"` PromoteLevelLabel string `json:"promoteLevelLabel"` Status *int `json:"status"` diff --git a/web/src/api/channel.js b/web/src/api/channel.js index 7c07021..4d56ce2 100644 --- a/web/src/api/channel.js +++ b/web/src/api/channel.js @@ -1,6 +1,6 @@ import service from '@/utils/businessRequest' -export const listChannels = params => service({ url: '/sw/promote/list', method: 'get', params }) +export const listChannels = (params, options = {}) => service({ ...options, url: '/sw/promote/list', method: 'get', params }) export const channelBindOptions = (sourceDb, options = {}) => service({ ...options, url: '/sw/promote/bindOptions', method: 'get', params: { sourceDb } }) export const createChannel = data => service({ url: '/sw/promote', method: 'post', data }) export const channelGames = (params, options = {}) => service({ ...options, url: '/sw/promote/game-distribution', method: 'get', params }) diff --git a/web/src/view/sw/BusinessPerformance.vue b/web/src/view/sw/BusinessPerformance.vue index f25b217..6f6c1e2 100644 --- a/web/src/view/sw/BusinessPerformance.vue +++ b/web/src/view/sw/BusinessPerformance.vue @@ -11,10 +11,10 @@
| {{ period.label }} | ||||||
|---|---|---|---|---|---|---|
| {{ metric.label }} | ||||||
| {{ period.label }} | ||||||
| {{ metric.label }} | ||||||
| {{ row.groupName || '—' }} {{ row.memberCount }} 人 |
{{ row.name }} {{ businessLevels[row.member.level] }} |
@@ -23,7 +23,7 @@
{{ 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) }} | |||
| 可见范围合计 | {{ displayMetric(metric.key, data?.matrixTotal?.[metric.key] ?? 0) }} | {{ data?.matrixRate == null ? '—' : `${data.matrixRate}%` }} | {{ displayMetric(metric.key, data?.matrixTrend?.[period.key]?.[metric.key] ?? 0) }} | |||