fix: align business pages and detail drawers with prototype

This commit is contained in:
Yvan
2026-11-05 16:18:39 +08:00
parent 27cacffc7f
commit db11b6eb54
28 changed files with 603 additions and 87 deletions
+41 -4
View File
@@ -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
+9 -4
View File
@@ -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("密码长度未校验")
}
}
+32 -5
View File
@@ -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) {
+10 -11
View File
@@ -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()