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 TestPerformanceMatrixTotalsDeduplicateDisplayedMembers(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.Groups[0].UniqueTotal.Open != 1 { t.Fatalf("matrix and group subtotal must reflect the displayed member scope: %+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.Effective != 0 || page.MatrixTotal.Pay != "0.00" || page.Total.Open != 2 { t.Fatalf("empty displayed rows must not erase visible-scope totals: %+v %v", page, err) } for period, total := range page.MatrixTrend { if total.Open != 0 || total.Effective != 0 || total.Pay != "0.00" { t.Fatalf("empty matrix retained period values: %s", period) } } f.GroupID = nil page, err = uc.Query(ctx, f) if err != nil || page.MatrixTotal != page.Total || page.MatrixTotal.Open != 2 { t.Fatalf("overlapping ancestors must contribute only once: %+v %v", page, err) } } 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) { _, 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("重算失败不能发布部分结果或修改规则") } }