Files
kra-new/internal/biz/business/management_test.go
T

388 lines
16 KiB
Go

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, &copy)
}
}
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("重算失败不能发布部分结果或修改规则")
}
}