356 lines
15 KiB
Go
356 lines
15 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, ©)
|
|
}
|
|
}
|
|
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 TestPerformanceMatrixTotalsFollowDisplayedMemberUnion(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.MatrixTotal.Effective != 1 || page.MatrixTotal.Pay != "9007199254740993.03" || page.Groups[0].UniqueTotal != page.MatrixTotal {
|
|
t.Fatalf("filtered member union/subtotal mismatch: %+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)
|
|
}
|
|
}
|
|
|
|
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("重算失败不能发布部分结果或修改规则")
|
|
}
|
|
}
|