fix: align business pages and detail drawers with prototype
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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("密码长度未校验")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user