feat: 完善小组绩效与有效端统计并修复审查问题

This commit is contained in:
Yvan
2026-11-05 01:10:03 +08:00
parent ca9ce99b66
commit dc97690eb1
75 changed files with 5033 additions and 296 deletions
+1 -1
View File
@@ -11,4 +11,4 @@ import (
)
// ProviderSet is biz providers.
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewOtherSettingsUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase, business.NewActivationUsecase, business.NewChannelUsecase, business.NewBusinessPortraitUsecase, business.NewChannelPortraitUsecase, business.NewLedgerUsecase, business.NewAnalyticsUsecase, business.NewServerAnalysisUsecase, business.NewWorkbenchUsecase, business.NewRankingUsecase, business.NewReportBuildUsecase, business.NewAlertTriggerUsecase)
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewOtherSettingsUsecase, business.NewTeamUsecase, business.NewGoalUsecase, business.NewEffectivenessUsecase, business.NewPerformanceUsecase, business.NewDashboardUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase, business.NewActivationUsecase, business.NewChannelUsecase, business.NewBusinessPortraitUsecase, business.NewChannelPortraitUsecase, business.NewLedgerUsecase, business.NewAnalyticsUsecase, business.NewServerAnalysisUsecase, business.NewWorkbenchUsecase, business.NewRankingUsecase, business.NewReportBuildUsecase, business.NewAlertTriggerUsecase)
+4
View File
@@ -23,6 +23,7 @@ var (
)
type Account struct {
HireDate, Employment string
ID uint
Username, NickName, Password, RealName, Phone, Email, QQNumber string
Level int
@@ -218,6 +219,9 @@ func (uc *AccountUsecase) Update(ctx context.Context, input *AccountUpdate) erro
if account.AuthorityID == 888 {
return ErrAccountScope
}
if input.Username != nil && *input.Username != account.Username || input.NickName != nil && *input.NickName != account.NickName {
return ErrAccountInput
}
// 编辑资料和审核不会改变商务层级、归属、角色或密码。
for _, field := range []struct {
dst *string
+24 -15
View File
@@ -55,31 +55,34 @@ func (r *AnalyticsRow) BusinessName() string {
}
type AnalyticsData struct {
Rows []*AnalyticsRow
GlobalCounts *PortraitCounts
GlobalPay string
Available bool
EffectiveCount int64
Rows []*AnalyticsRow
GlobalCounts *PortraitCounts
GlobalPay string
Available bool
}
type AnalyticsPage struct {
Rows []*AnalyticsRow
Total int64
Summary *AnalyticsRow
Begin, End string
Available bool
EffectiveCount int64
Rows []*AnalyticsRow
Total int64
Summary *AnalyticsRow
Begin, End string
Available bool
}
type AnalyticsRepo interface {
Aggregate(context.Context, AnalyticsKind, AnalyticsScope, AnalyticsFilter) (*AnalyticsData, error)
FillNames(context.Context, AnalyticsKind, AnalyticsScope, AnalyticsFilter, []*AnalyticsRow) error
}
type AnalyticsUsecase struct {
repo AnalyticsRepo
accounts *AccountUsecase
preferences *UserPreferenceUsecase
now func() time.Time
repo AnalyticsRepo
accounts *AccountUsecase
preferences *UserPreferenceUsecase
now func() time.Time
effectiveness *EffectivenessUsecase
}
func NewAnalyticsUsecase(repo AnalyticsRepo, accounts *AccountUsecase, preferences *UserPreferenceUsecase) *AnalyticsUsecase {
return &AnalyticsUsecase{repo: repo, accounts: accounts, preferences: preferences, now: time.Now}
func NewAnalyticsUsecase(repo AnalyticsRepo, accounts *AccountUsecase, preferences *UserPreferenceUsecase, effectiveness *EffectivenessUsecase) *AnalyticsUsecase {
return &AnalyticsUsecase{repo: repo, accounts: accounts, preferences: preferences, now: time.Now, effectiveness: effectiveness}
}
func analyticsCap(c PortraitCounts) PortraitCounts {
return PortraitCounts{Register: min(c.Register, 2147483647), Active: min(c.Active, 2147483647), Pay: min(c.Pay, 2147483647), NewPay: min(c.NewPay, 2147483647)}
@@ -140,6 +143,11 @@ func (uc *AnalyticsUsecase) List(ctx context.Context, kind AnalyticsKind, filter
filter.End = filter.EndDate.Format("2006-01-02")
out := &AnalyticsPage{Rows: []*AnalyticsRow{}, Begin: filter.Begin, End: filter.End, Available: true}
scope := AnalyticsScope{Viewer: viewer, IncludeSub: pref.IncludeSubPromote, IDCardDedup: pref.IDCardDedup}
if kind == ChannelAnalytics && uc.effectiveness != nil {
if err := uc.effectiveness.Ensure(ctx); err != nil {
return nil, err
}
}
data, err := uc.repo.Aggregate(ctx, kind, scope, filter)
if err != nil {
return nil, err
@@ -148,6 +156,7 @@ func (uc *AnalyticsUsecase) List(ctx context.Context, kind AnalyticsKind, filter
out.Available = false
return out, nil
}
out.EffectiveCount = data.EffectiveCount
counts := PortraitCounts{}
pay := new(big.Rat)
for _, row := range data.Rows {
+1 -1
View File
@@ -40,7 +40,7 @@ func (r *analyticsRepoFake) Aggregate(_ context.Context, _ AnalyticsKind, s Anal
func analyticsFixture() (*AnalyticsUsecase, *analyticsRepoFake) {
a := &accountRepoFake{accounts: map[uint]*Account{1: {ID: 1}, 7: {ID: 7, Level: 1, IDPath: "0"}}}
r := &analyticsRepoFake{data: &AnalyticsData{Available: true, Rows: []*AnalyticsRow{{AnalyticsKey: AnalyticsKey{SourceDB: "lx", ID: 1}, Name: "甲", Counts: PortraitCounts{Register: 2, Active: 4, Pay: 2, NewPay: 1}, Pay: "20.00"}, {AnalyticsKey: AnalyticsKey{SourceDB: "hw", ID: 2}, Name: "乙", Counts: PortraitCounts{Register: 3, Active: 6, Pay: 1, NewPay: 1}, Pay: "30.00"}}, GlobalCounts: &PortraitCounts{Register: 4, Active: 8, Pay: 3}, GlobalPay: "45.00"}}
uc := NewAnalyticsUsecase(r, NewAccountUsecase(a), NewUserPreferenceUsecase(&channelPreferenceFake{UserPreference{IncludeSubPromote: true, IDCardDedup: true}}))
uc := NewAnalyticsUsecase(r, NewAccountUsecase(a), NewUserPreferenceUsecase(&channelPreferenceFake{UserPreference{IncludeSubPromote: true, IDCardDedup: true}}), nil)
uc.now = func() time.Time { return time.Date(2026, 11, 4, 9, 0, 0, 0, portraitLocation) }
return uc, r
}
+16 -8
View File
@@ -41,6 +41,7 @@ type Channel struct {
GameCount, PlayerCount *int64
TotalPayAmount *string
CreatedUnix, LastLoginUnix *int64
QualifiedAt *int64
}
func (c *Channel) BusinessDisplayName() string {
@@ -70,6 +71,7 @@ func (c *Channel) IncludesStat(other *Channel, includeSub bool) bool {
}
type ChannelFilter struct {
Effective *bool
Sources []string
BusierUserID *uint
BusierAccount, Keyword string
@@ -133,16 +135,17 @@ type ChannelWriteRepo interface {
}
type PlatformPasswordEncoder interface{ Encode(string) (string, error) }
type ChannelUsecase struct {
repo ChannelRepo
writer ChannelWriteRepo
accounts *AccountUsecase
preferences *UserPreferenceUsecase
passwords PlatformPasswordEncoder
now func() time.Time
repo ChannelRepo
writer ChannelWriteRepo
accounts *AccountUsecase
preferences *UserPreferenceUsecase
passwords PlatformPasswordEncoder
effectiveness *EffectivenessUsecase
now func() time.Time
}
func NewChannelUsecase(repo ChannelRepo, writer ChannelWriteRepo, accounts *AccountUsecase, preferences *UserPreferenceUsecase, passwords PlatformPasswordEncoder) *ChannelUsecase {
return &ChannelUsecase{repo: repo, writer: writer, accounts: accounts, preferences: preferences, passwords: passwords, now: time.Now}
func NewChannelUsecase(repo ChannelRepo, writer ChannelWriteRepo, accounts *AccountUsecase, preferences *UserPreferenceUsecase, passwords PlatformPasswordEncoder, effectiveness *EffectivenessUsecase) *ChannelUsecase {
return &ChannelUsecase{repo: repo, writer: writer, accounts: accounts, preferences: preferences, passwords: passwords, now: time.Now, effectiveness: effectiveness}
}
func (uc *ChannelUsecase) scope(ctx context.Context) (UserScope, error) {
actor, a, err := uc.accounts.actor(ctx)
@@ -170,6 +173,11 @@ func (uc *ChannelUsecase) List(ctx context.Context, filter ChannelFilter, option
if err != nil {
return nil, err
}
if uc.effectiveness != nil {
if err := uc.effectiveness.Ensure(ctx); err != nil {
return nil, err
}
}
page, err := uc.repo.List(ctx, scope, filter, options...)
if err != nil || len(page.Rows) == 0 {
return page, err
+1 -1
View File
@@ -71,7 +71,7 @@ func channelFixture() (*ChannelUsecase, *channelRepoFake, *channelPreferenceFake
p := &channelPreferenceFake{UserPreference{IncludeSubPromote: true, IDCardDedup: true}}
w, e := &channelWriteFake{}, &channelEncoderFake{}
a := &accountRepoFake{accounts: map[uint]*Account{1: {ID: 1}, 7: {ID: 7, Level: 1, IDPath: "0"}, 8: {ID: 8, Level: 2, IDPath: "0,7"}, 9: {ID: 9, Level: 3, IDPath: "0,7,8"}}}
uc := NewChannelUsecase(r, w, NewAccountUsecase(a), NewUserPreferenceUsecase(p), e)
uc := NewChannelUsecase(r, w, NewAccountUsecase(a), NewUserPreferenceUsecase(p), e, nil)
uc.now = func() time.Time { return time.Date(2026, 11, 3, 12, 0, 0, 0, time.UTC) }
return uc, r, p, w, e
}
+104
View File
@@ -0,0 +1,104 @@
package business
import (
"context"
"strconv"
"time"
)
type DashboardWindow struct{ Begin, End, PreviousBegin, PreviousEnd time.Time }
type DashboardMetrics struct {
Register, Active, Effective, ActiveChannels, NewChannels int64
LockedChannels, TerminatedChannels int64
Pay string
}
type DashboardData struct {
Current, Previous DashboardMetrics
Available bool
EventsAvailable bool
}
type DashboardRepo interface {
Overview(context.Context, AnalyticsScope, []string, DashboardWindow) (*DashboardData, error)
}
type DashboardOverview struct {
Cards map[string]*WorkbenchCard
Begin, End string
Settings OtherSettings
Available bool
}
type DashboardUsecase struct {
repo DashboardRepo
workbench *WorkbenchUsecase
effective *EffectivenessUsecase
now func() time.Time
}
func NewDashboardUsecase(repo DashboardRepo, workbench *WorkbenchUsecase, effective *EffectivenessUsecase) *DashboardUsecase {
return &DashboardUsecase{repo: repo, workbench: workbench, effective: effective, now: time.Now}
}
func (uc *DashboardUsecase) Overview(ctx context.Context, sources []string, period string, begin, end *time.Time) (*DashboardOverview, error) {
scope, err := uc.workbench.scope(ctx, sources)
if err != nil {
return nil, err
}
today := workbenchWindow(uc.now()).Today
b, e := today, today
switch period {
case "", "today":
case "yesterday":
b, e = today.AddDate(0, 0, -1), today.AddDate(0, 0, -1)
case "last7":
b = today.AddDate(0, 0, -6)
case "last30":
b = today.AddDate(0, 0, -29)
case "custom":
if begin == nil || end == nil {
return nil, ErrPerformanceInput
}
b, e = *begin, *end
default:
return nil, ErrPerformanceInput
}
if e.Before(b) || e.After(today) {
return nil, ErrPerformanceInput
}
days := int(e.Sub(b).Hours()/24) + 1
window := DashboardWindow{Begin: b, End: e, PreviousBegin: b.AddDate(0, 0, -days), PreviousEnd: b.AddDate(0, 0, -1)}
if err := uc.effective.Ensure(ctx); err != nil {
return nil, err
}
rule, err := uc.effective.Rule(ctx)
if err != nil {
return nil, err
}
data, err := uc.repo.Overview(ctx, scope, sources, window)
if err != nil {
return nil, err
}
r := &DashboardOverview{Cards: map[string]*WorkbenchCard{}, Begin: b.Format("2006-01-02"), End: e.Format("2006-01-02"), Settings: rule, Available: data.Available}
num := func(v int64) string { return strconv.FormatInt(v, 10) }
for _, card := range []struct{ key, current, previous string }{{"todayRegister", num(data.Current.Register), num(data.Previous.Register)}, {"todayActive", num(data.Current.Active), num(data.Previous.Active)}, {"todayPay", data.Current.Pay, data.Previous.Pay}, {"effectivePromote", num(data.Current.Effective), num(data.Previous.Effective)}, {"activePromote", num(data.Current.ActiveChannels), num(data.Previous.ActiveChannels)}, {"newPromote", num(data.Current.NewChannels), num(data.Previous.NewChannels)}} {
value, err := workbenchCard(card.current, card.previous, "较上一同期", data.Available)
if err != nil {
return nil, err
}
r.Cards[card.key] = value
}
// The imported SDK channel table has only current state; historical event
// counts must stay unavailable until a timestamped event source is connected.
for _, card := range []struct {
key string
current, previous int64
}{{"lockedPromote", data.Current.LockedChannels, data.Previous.LockedChannels}, {"terminatedPromote", data.Current.TerminatedChannels, data.Previous.TerminatedChannels}} {
label := "较上一同期"
if !data.EventsAvailable {
label = "事件数据未接入"
}
value, err := workbenchCard(num(card.current), num(card.previous), label, data.Available && data.EventsAvailable)
if err != nil {
return nil, err
}
r.Cards[card.key] = value
}
return r, nil
}
+148
View File
@@ -0,0 +1,148 @@
package business
import (
"context"
"crypto/sha256"
"fmt"
"math"
"math/big"
"strconv"
"time"
"github.com/go-kratos/kratos/v3/errors"
)
var ErrEffectivenessConflict = errors.Conflict("SW_EFFECTIVENESS_CONFLICT", "有效端规则或数据已更新,请重试")
var ErrEffectivenessUnavailable = errors.BadRequest("SW_EFFECTIVENESS_UNAVAILABLE", "有效端计算所需渠道或充值数据尚未接入")
func EffectivenessRuleKey(rule OtherSettings) string {
return fmt.Sprintf("%x", sha256.Sum256([]byte(strconv.FormatInt(rule.Amount, 10)+":"+strconv.FormatInt(rule.Days, 10))))
}
type EffectivenessUsecase struct {
repo EffectivenessRepo
settings OtherSettingsRepo
gate chan struct{}
now func() time.Time
}
func NewEffectivenessUsecase(repo EffectivenessRepo, settings OtherSettingsRepo) *EffectivenessUsecase {
gate := make(chan struct{}, 1)
gate <- struct{}{}
return &EffectivenessUsecase{repo: repo, settings: settings, gate: gate, now: time.Now}
}
func (uc *EffectivenessUsecase) Rule(ctx context.Context) (OtherSettings, error) {
v, err := uc.settings.Find(ctx)
if err != nil {
return OtherSettings{}, err
}
if v == nil {
return DefaultOtherSettings(), nil
}
return *v, nil
}
func (uc *EffectivenessUsecase) Ensure(ctx context.Context) error {
rule, err := uc.Rule(ctx)
if err != nil {
return err
}
state, err := uc.repo.State(ctx)
if err != nil {
return err
}
if state.RuleKey == EffectivenessRuleKey(rule) && !state.BuiltAt.IsZero() {
return nil
}
_, err = uc.rebuild(ctx, rule, false, false, true)
return err
}
func (uc *EffectivenessUsecase) Refresh(ctx context.Context, full bool) error {
rule, err := uc.Rule(ctx)
if err != nil {
return err
}
_, err = uc.rebuild(ctx, rule, false, full, false)
return err
}
func (uc *EffectivenessUsecase) Reconfigure(ctx context.Context, rule OtherSettings) (*OtherSettings, error) {
if err := requireSettingsAdmin(ctx); err != nil {
return nil, err
}
if err := ValidateOtherSettings(rule); err != nil {
return nil, err
}
return uc.rebuild(ctx, rule, true, true, false)
}
func (uc *EffectivenessUsecase) rebuild(ctx context.Context, rule OtherSettings, save, full, ensureOnly bool) (*OtherSettings, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-uc.gate:
}
defer func() { uc.gate <- struct{}{} }()
state, err := uc.repo.State(ctx)
if err != nil {
return nil, err
}
key := EffectivenessRuleKey(rule)
if ensureOnly && state.RuleKey == key && !state.BuiltAt.IsZero() {
return &rule, nil
}
full = full || state.RuleKey != key
duration := int64(math.MaxInt64)
if rule.Days <= math.MaxInt64/86400 {
duration = rule.Days * 86400
}
now := uc.now().Unix()
candidates, err := uc.repo.Candidates(ctx, key, duration, now, full)
if err != nil {
return nil, err
}
type accumulator struct {
candidate *EffectivenessCandidate
sum *big.Rat
at *int64
complete bool
}
values := map[ChannelKey]*accumulator{}
threshold := big.NewRat(rule.Amount, 1)
for _, c := range candidates {
a := &accumulator{candidate: c, sum: new(big.Rat), at: c.PreviousAt, complete: true}
if c.CreatedAt != nil && *c.CreatedAt > now {
a.complete = false
}
if c.CreatedAt != nil && *c.CreatedAt > 0 && *c.CreatedAt <= now {
a.complete = duration <= now-*c.CreatedAt
if rule.Amount == 0 {
at := *c.CreatedAt
a.at = &at
}
}
values[c.ChannelKey] = a
}
err = uc.repo.Charges(ctx, candidates, duration, now, full, func(charge EffectivenessCharge) error {
a := values[charge.ChannelKey]
if a == nil {
return nil
}
amount, err := portraitNumber(charge.Amount)
if err != nil {
return err
}
a.sum.Add(a.sum, amount)
if a.sum.Cmp(threshold) >= 0 && (a.at == nil || charge.PaidAt < *a.at) {
at := charge.PaidAt
a.at = &at
}
return nil
})
if err != nil {
return nil, err
}
results := make([]EffectivenessResult, 0, len(values))
for _, c := range candidates {
a := values[c.ChannelKey]
results = append(results, EffectivenessResult{ChannelKey: c.ChannelKey, QualifiedAt: a.at, WindowPay: portraitDecimal(a.sum, 2), Complete: a.complete})
}
return uc.repo.Publish(ctx, state, rule, key, results, save)
}
+89
View File
@@ -0,0 +1,89 @@
package business
import (
"math/big"
"time"
)
func GoalMetric(goal *PerformanceGoal, key string) *int64 {
if goal == nil {
return nil
}
switch key {
case "open":
return goal.Open
case "effective":
return goal.Effective
case "pay":
return goal.Amount
}
return nil
}
func GoalPeriodTarget(goal *PerformanceGoal, key, period, grain string) *string {
if len(period) < 7 {
return nil
}
value := GoalMetric(goal, key)
if value == nil {
return nil
}
target := big.NewRat(*value, 1)
if grain == "day" {
month, err := time.Parse("2006-01", period[:7])
if err != nil {
return nil
}
target.Quo(target, big.NewRat(int64(month.AddDate(0, 1, -1).Day()), 1))
}
return stringPointer(portraitDecimal(target, 2))
}
func GoalCompletion(periods map[string]PerformanceMetrics, goals map[string]*PerformanceGoal, key string) *string {
actual, target := new(big.Rat), new(big.Rat)
months := map[string]bool{}
for period, value := range periods {
if len(period) < 7 {
continue
}
month := period[:7]
goal := GoalMetric(goals[month], key)
if goal == nil {
continue
}
if !months[month] {
target.Add(target, big.NewRat(*goal, 1))
months[month] = true
}
switch key {
case "open":
actual.Add(actual, big.NewRat(value.Open, 1))
case "effective":
actual.Add(actual, big.NewRat(value.Effective, 1))
case "pay":
amount, err := portraitNumber(value.Pay)
if err == nil {
actual.Add(actual, amount)
}
}
}
if target.Sign() <= 0 {
return nil
}
return stringPointer(portraitDecimal(actual.Quo(actual, target).Mul(actual, big.NewRat(100, 1)), 1))
}
func PerformanceChange(current, previous PerformanceMetrics, key string) *string {
var a, b *big.Rat
switch key {
case "open":
a, b = big.NewRat(current.Open, 1), big.NewRat(previous.Open, 1)
case "effective":
a, b = big.NewRat(current.Effective, 1), big.NewRat(previous.Effective, 1)
case "pay":
a, _ = portraitNumber(current.Pay)
b, _ = portraitNumber(previous.Pay)
}
if a == nil || b == nil || b.Sign() == 0 {
return nil
}
return stringPointer(portraitDecimal(a.Sub(a, b).Quo(a, b).Mul(a, big.NewRat(100, 1)), 1))
}
func PerformanceRate(value PerformanceMetrics) *string { return performanceRate(value) }
+355
View File
@@ -0,0 +1,355 @@
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 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("重算失败不能发布部分结果或修改规则")
}
}
+9 -3
View File
@@ -35,10 +35,13 @@ type OtherSettingsRepo interface {
Save(context.Context, OtherSettings) (*OtherSettings, error)
}
type OtherSettingsUsecase struct{ repo OtherSettingsRepo }
type OtherSettingsUsecase struct {
repo OtherSettingsRepo
effectiveness *EffectivenessUsecase
}
func NewOtherSettingsUsecase(repo OtherSettingsRepo) *OtherSettingsUsecase {
return &OtherSettingsUsecase{repo: repo}
func NewOtherSettingsUsecase(repo OtherSettingsRepo, effectiveness *EffectivenessUsecase) *OtherSettingsUsecase {
return &OtherSettingsUsecase{repo: repo, effectiveness: effectiveness}
}
func requireSettingsAdmin(ctx context.Context) error {
@@ -74,5 +77,8 @@ func (uc *OtherSettingsUsecase) Save(ctx context.Context, value OtherSettings) (
if err := ValidateOtherSettings(value); err != nil {
return nil, err
}
if uc.effectiveness != nil {
return uc.effectiveness.Reconfigure(ctx, value)
}
return uc.repo.Save(ctx, value)
}
+2 -2
View File
@@ -22,7 +22,7 @@ func (r *otherSettingsFake) Save(_ context.Context, value OtherSettings) (*Other
func TestOtherSettingsOnlySuperAdminAndDefaults(t *testing.T) {
r := &otherSettingsFake{}
uc := NewOtherSettingsUsecase(r)
uc := NewOtherSettingsUsecase(r, nil)
for _, ctx := range []context.Context{context.Background(), accountContext(7, 901), accountContext(8, 902), accountContext(9, 903)} {
if _, err := uc.Get(ctx); err == nil {
t.Fatal("非超管不能读取设置")
@@ -48,7 +48,7 @@ func TestOtherSettingsOnlySuperAdminAndDefaults(t *testing.T) {
}
func TestOtherSettingsRejectInvalidRange(t *testing.T) {
uc := NewOtherSettingsUsecase(&otherSettingsFake{})
uc := NewOtherSettingsUsecase(&otherSettingsFake{}, nil)
for _, value := range []OtherSettings{{Amount: -1, Days: 30}, {Amount: 3000, Days: 0}, {Amount: MaxSettingsInteger + 1, Days: 30}, {Amount: 3000, Days: MaxSettingsInteger + 1}} {
if _, err := uc.Save(accountContext(1, 888), value); err != ErrOtherSettingsInput {
t.Fatalf("不应保存非法值: %+v %v", value, err)
+528
View File
@@ -0,0 +1,528 @@
package business
import (
"context"
"math/big"
"sort"
"strings"
"time"
"github.com/go-kratos/kratos/v3/errors"
)
var ErrPerformanceInput = errors.BadRequest("SW_PERFORMANCE_INPUT", "绩效统计区间或查询对象不正确")
type PerformanceUsecase struct {
repo PerformanceRepo
teams *TeamUsecase
effective *EffectivenessUsecase
goals GoalRepo
now func() time.Time
}
func NewPerformanceUsecase(repo PerformanceRepo, teams *TeamUsecase, effective *EffectivenessUsecase, goals GoalRepo) *PerformanceUsecase {
return &PerformanceUsecase{repo: repo, teams: teams, effective: effective, goals: goals, now: time.Now}
}
type performanceAccumulator struct {
open, effective int64
pay *big.Rat
}
func newPerformanceAccumulator() *performanceAccumulator {
return &performanceAccumulator{pay: new(big.Rat)}
}
func (a *performanceAccumulator) metrics() PerformanceMetrics {
return PerformanceMetrics{Open: a.open, Effective: a.effective, Pay: portraitDecimal(a.pay, 2)}
}
func performanceRate(m PerformanceMetrics) *string {
if m.Open == 0 {
return nil
}
return stringPointer(portraitDecimal(new(big.Rat).Mul(big.NewRat(m.Effective, m.Open), big.NewRat(100, 1)), 1))
}
func addPerformance(a, b PerformanceMetrics) PerformanceMetrics {
x, _ := portraitNumber(a.Pay)
y, _ := portraitNumber(b.Pay)
if x == nil {
x = new(big.Rat)
}
if y == nil {
y = new(big.Rat)
}
return PerformanceMetrics{Open: a.Open + b.Open, Effective: a.Effective + b.Effective, Pay: portraitDecimal(x.Add(x, y), 2)}
}
type performanceBuckets struct {
total *performanceAccumulator
periods map[string]*performanceAccumulator
}
func newPerformanceBuckets(periods []PerformancePeriod) *performanceBuckets {
b := &performanceBuckets{total: newPerformanceAccumulator(), periods: map[string]*performanceAccumulator{}}
for _, p := range periods {
b.periods[p.Key] = newPerformanceAccumulator()
}
return b
}
func (b *performanceBuckets) add(key, metric string, pay *big.Rat) {
a := b.periods[key]
if a == nil {
return
}
switch metric {
case "open":
a.open++
b.total.open++
case "effective":
a.effective++
b.total.effective++
case "pay":
a.pay.Add(a.pay, pay)
b.total.pay.Add(b.total.pay, pay)
}
}
func (b *performanceBuckets) values() map[string]PerformanceMetrics {
m := map[string]PerformanceMetrics{}
for key, value := range b.periods {
m[key] = value.metrics()
}
return m
}
func performanceWindow(now time.Time, f PerformanceFilter) (time.Time, time.Time, []PerformancePeriod, string, error) {
now = now.In(portraitLocation)
if f.ForManagement {
begin, end := DefaultPerformanceRange(now)
periods := []PerformancePeriod{}
for m := begin; !m.After(end); m = m.AddDate(0, 1, 0) {
last := m.AddDate(0, 1, -1)
periods = append(periods, PerformancePeriod{Key: m.Format("2006-01"), Label: m.Format("1月"), Begin: m.Format("2006-01-02"), End: last.Format("2006-01-02")})
}
return begin, end, periods, "month", nil
}
if f.Year == 0 {
f.Year = now.Year()
}
if f.BeginMonth == 0 {
f.BeginMonth = int(now.Month())
}
if f.EndMonth == 0 {
f.EndMonth = f.BeginMonth
}
if f.Year < 1970 || f.Year > now.Year()+1 || f.BeginMonth < 1 || f.EndMonth > 12 || f.EndMonth < f.BeginMonth {
return time.Time{}, time.Time{}, nil, "", ErrPerformanceInput
}
begin := time.Date(f.Year, time.Month(f.BeginMonth), 1, 0, 0, 0, 0, portraitLocation)
end := time.Date(f.Year, time.Month(f.EndMonth)+1, 0, 0, 0, 0, 0, portraitLocation)
granularity := "month"
periods := []PerformancePeriod{}
if f.BeginMonth == f.EndMonth {
granularity = "day"
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, portraitLocation)
for day := begin; !day.After(end) && !day.After(today); day = day.AddDate(0, 0, 1) {
date := day.Format("2006-01-02")
periods = append(periods, PerformancePeriod{Key: date, Label: day.Format("1月2日"), Begin: date, End: date})
}
} else {
for month := begin; !month.After(end); month = month.AddDate(0, 1, 0) {
last := month.AddDate(0, 1, -1)
periods = append(periods, PerformancePeriod{Key: month.Format("2006-01"), Label: month.Format("1月"), Begin: month.Format("2006-01-02"), End: last.Format("2006-01-02")})
}
}
return begin, end, periods, granularity, nil
}
func (uc *PerformanceUsecase) Query(ctx context.Context, f PerformanceFilter) (*PerformancePage, error) {
return uc.query(ctx, f, nil)
}
func (uc *PerformanceUsecase) query(ctx context.Context, f PerformanceFilter, ownerIDs []uint) (*PerformancePage, error) {
view, err := uc.teams.View(ctx)
if err != nil {
return nil, err
}
scope := view.Scope
snapshotTime := uc.now().In(portraitLocation)
begin, end, periods, grain, err := performanceWindow(snapshotTime, f)
if err != nil {
return nil, err
}
if !begin.After(end) {
if err := uc.effective.Ensure(ctx); err != nil {
return nil, err
}
}
settings, err := uc.effective.Rule(ctx)
if err != nil {
return nil, err
}
priorBegin := begin.AddDate(0, -1, 0)
priorKey := priorBegin.Format("2006-01")
if grain == "day" {
priorBegin = begin.AddDate(0, 0, -1)
priorKey = priorBegin.Format("2006-01-02")
}
facts := &PerformanceFacts{Available: true}
if !begin.After(end) {
facts, err = uc.repo.Facts(ctx, scope, ownerIDs, nil, priorBegin, end)
}
if err != nil {
return nil, err
}
out := &PerformancePage{Rows: []*PerformanceRow{}, Groups: []*PerformanceGroupRow{}, Periods: periods, Trend: map[string]PerformanceMetrics{}, Begin: begin.Format("2006-01-02"), End: end.Format("2006-01-02"), Granularity: grain, Settings: settings, Available: facts.Available}
if !facts.Available {
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))
}
matrixVisible := func(m *BusinessMember) bool {
return visible(m) && (f.GroupID == nil || m.GroupID == *f.GroupID)
}
prior := newPerformanceAccumulator()
memberBuckets := map[uint]*performanceBuckets{}
groupBuckets := map[uint64]*performanceBuckets{}
byID := map[uint]*BusinessMember{}
families := map[uint][]uint{}
for _, member := range view.Members {
byID[member.ID] = member
memberBuckets[member.ID] = newPerformanceBuckets(periods)
}
descendants := map[uint]int{}
for _, owner := range view.Members {
families[owner.ID] = memberAncestors(owner, byID)
for _, ancestor := range families[owner.ID] {
if ancestor != owner.ID {
descendants[ancestor]++
}
}
}
groupsByMember := map[uint][]uint64{}
for _, group := range view.Groups {
groupBuckets[group.ID] = newPerformanceBuckets(periods)
for _, id := range group.MemberIDs {
if matrixVisible(byID[id]) {
groupsByMember[id] = append(groupsByMember[id], group.ID)
}
}
}
type attribution struct {
members []uint
groups []uint64
inMatrix bool
}
owners := map[ChannelKey]attribution{}
forOwners := func(ownerIDs []uint) attribution {
members, groups := map[uint]bool{}, map[uint64]bool{}
for _, owner := range ownerIDs {
for _, id := range families[owner] {
members[id] = true
for _, g := range groupsByMember[id] {
groups[g] = true
}
}
}
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)
}
return a
}
productive := map[uint]bool{}
periodKey := func(date string) string {
if grain == "month" && len(date) >= 7 {
return date[:7]
}
return date
}
dateKey := func(value *int64) string {
if value == nil || *value <= 0 || *value > snapshotTime.Unix() {
return ""
}
return periodKey(time.Unix(*value, 0).In(portraitLocation).Format("2006-01-02"))
}
add := func(a attribution, key, metric string, pay *big.Rat) {
if key == priorKey {
switch metric {
case "open":
prior.open++
case "effective":
prior.effective++
case "pay":
prior.pay.Add(prior.pay, pay)
}
}
if all.periods[key] == nil {
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)
}
for _, id := range a.groups {
groupBuckets[id].add(key, metric, pay)
}
}
for _, channel := range facts.Channels {
a := forOwners(channel.OwnerIDs)
owners[channel.ChannelKey] = a
open, effective := dateKey(channel.CreatedAt), dateKey(channel.QualifiedAt)
add(a, open, "open", nil)
add(a, effective, "effective", nil)
if all.periods[open] != nil || all.periods[effective] != nil {
for _, owner := range channel.OwnerIDs {
if byID[owner] != nil {
productive[owner] = true
}
}
}
}
ownerAttributions := map[ChannelKey]attribution{}
for _, cash := range facts.Cash {
if cash.Date > snapshotTime.Format("2006-01-02") {
continue
}
a, ok := owners[cash.ChannelKey]
if cash.OwnerIDs != nil {
a, ok = ownerAttributions[cash.ChannelKey]
if !ok {
a = forOwners(cash.OwnerIDs)
ownerAttributions[cash.ChannelKey] = a
}
ok = true
}
if !ok {
continue
}
pay, err := portraitNumber(cash.Pay)
if err != nil {
return nil, err
}
add(a, periodKey(cash.Date), "pay", pay)
}
goals, err := uc.goals.List(ctx, begin.Format("2006-01"), end.Format("2006-01"))
if err != nil {
return nil, err
}
personGoals, groupGoals := map[uint64]map[string]*PerformanceGoal{}, map[uint64]map[string]*PerformanceGoal{}
for _, goal := range goals {
target := personGoals
if goal.Kind == "group" {
target = groupGoals
}
if target[goal.TargetID] == nil {
target[goal.TargetID] = map[string]*PerformanceGoal{}
}
target[goal.TargetID][goal.Month] = goal
}
rows := map[uint]*PerformanceRow{}
for _, member := range view.Members {
b := memberBuckets[member.ID]
total := b.total.metrics()
row := &PerformanceRow{Member: member, Total: total, Rate: performanceRate(total), Periods: b.values(), Goals: personGoals[uint64(member.ID)]}
row.DescendantCount = descendants[member.ID]
rows[member.ID] = row
if matrixVisible(member) {
out.Rows = append(out.Rows, row)
}
}
for _, group := range view.Groups {
if len(group.MemberIDs) == 0 {
continue
}
b := groupBuckets[group.ID]
g := &PerformanceGroupRow{Group: group, Total: PerformanceMetrics{Pay: "0.00"}, UniqueTotal: b.total.metrics(), UniquePeriods: b.values(), Periods: map[string]PerformanceMetrics{}, Members: []*PerformanceRow{}, Goals: groupGoals[group.ID]}
for _, period := range periods {
g.Periods[period.Key] = PerformanceMetrics{Pay: "0.00"}
}
match := false
for _, id := range group.MemberIDs {
if row := rows[id]; row != nil {
g.Members = append(g.Members, row)
g.Total = addPerformance(g.Total, row.Total)
for key, value := range row.Periods {
g.Periods[key] = addPerformance(g.Periods[key], value)
}
if visible(row.Member) {
match = true
}
}
}
if match {
out.Groups = append(out.Groups, g)
}
}
out.Total = all.total.metrics()
out.MatrixTotal = matrix.total.metrics()
out.MatrixRate = performanceRate(out.MatrixTotal)
out.MatrixTrend = matrix.values()
out.Prior = prior.metrics()
out.Rate = performanceRate(out.Total)
out.Trend = all.values()
out.ProductiveMembers = int64(len(productive))
groupOrder := map[uint64]int{}
for i, g := range view.Groups {
groupOrder[g.ID] = i + 1
}
sort.SliceStable(out.Rows, func(i, j int) bool {
a, b := out.Rows[i], out.Rows[j]
ga, gb := a.Member.GroupID, b.Member.GroupID
if ga != gb {
if ga == 0 {
return false
}
if gb == 0 {
return true
}
return groupOrder[ga] < groupOrder[gb]
}
comparison := int64(0)
switch f.SortField {
case "open":
comparison = a.Total.Open - b.Total.Open
case "effective":
comparison = a.Total.Effective - b.Total.Effective
case "rate":
// Undefined rates always follow defined rates in either direction.
if a.Total.Open == 0 || b.Total.Open == 0 {
return a.Total.Open != 0 && b.Total.Open == 0
}
comparison = int64(big.NewRat(a.Total.Effective, a.Total.Open).Cmp(big.NewRat(b.Total.Effective, b.Total.Open)))
}
if comparison == 0 {
return false
}
if f.Ascending {
return comparison < 0
}
return comparison > 0
})
return out, nil
}
func (uc *PerformanceUsecase) Target(ctx context.Context, kind string, id uint64) (UserScope, []uint, string, bool, error) {
view, err := uc.teams.View(ctx)
if err != nil {
return UserScope{}, nil, "", false, err
}
selection, name, editable, err := performanceTarget(view, kind, id, nil)
return view.Scope, selection.OwnerIDs, name, editable, err
}
func performanceTarget(view *TeamView, kind string, id uint64, selected []uint) (PerformanceDetailSelection, string, bool, error) {
scope := view.Scope
rootIDs := []uint{}
name := ""
editable := false
if kind == "person" {
for _, m := range view.Members {
if uint64(m.ID) == id {
rootIDs = append(rootIDs, m.ID)
name = memberName(m)
editable = m.CanSetGoal
}
}
} else if kind == "group" {
for _, g := range view.Groups {
if g.ID == id {
rootIDs = append(rootIDs, g.MemberIDs...)
name = g.Name
editable = g.CanSetGoal
}
}
} else if kind == "scope" && id == 0 {
if scope.All {
return PerformanceDetailSelection{}, "可见范围", false, nil
}
for _, m := range view.Members {
rootIDs = append(rootIDs, m.ID)
}
name = "可见范围"
} else {
return PerformanceDetailSelection{}, "", false, ErrPerformanceInput
}
if len(rootIDs) == 0 {
return PerformanceDetailSelection{}, "", false, ErrTeamScope
}
set := map[uint]bool{}
byID := map[uint]*BusinessMember{}
for _, m := range view.Members {
byID[m.ID] = m
}
roots := map[uint]bool{}
for _, root := range rootIDs {
roots[root] = true
}
if len(selected) > 0 {
if kind != "group" {
return PerformanceDetailSelection{}, "", false, ErrPerformanceInput
}
limited := map[uint]bool{}
for _, root := range selected {
if !roots[root] {
return PerformanceDetailSelection{}, "", false, ErrTeamScope
}
limited[root] = true
}
roots = limited
rootIDs = nil
for root := range roots {
rootIDs = append(rootIDs, root)
}
}
for _, m := range view.Members {
for _, ancestor := range memberAncestors(m, byID) {
if roots[ancestor] {
set[m.ID] = true
break
}
}
}
ids := make([]uint, 0, len(set))
for id := range set {
ids = append(ids, id)
}
return PerformanceDetailSelection{OwnerIDs: ids, ContributorIDs: rootIDs}, name, editable, nil
}
func (uc *PerformanceUsecase) Details(ctx context.Context, kind string, id uint64, begin, end time.Time, metric string, page, size int, members ...uint) ([]*PerformanceChannel, int64, error) {
view, err := uc.teams.View(ctx)
if err != nil {
return nil, 0, err
}
targetKind := kind
if kind == "team" {
targetKind = "group"
}
selection, _, _, err := performanceTarget(view, targetKind, id, members)
if err != nil {
return nil, 0, err
}
if kind != "team" {
selection.ContributorIDs = nil
}
if end.Before(begin) || !(metric == "open" || metric == "effective" || metric == "pay" || metric == "all") {
return nil, 0, ErrPerformanceInput
}
if page < 1 {
page = 1
}
if size < 1 || size > 500 {
size = 20
}
if page > int(^uint(0)>>1)/size {
return nil, 0, ErrPerformanceInput
}
if err := uc.effective.Ensure(ctx); err != nil {
return nil, 0, err
}
return uc.repo.Details(ctx, view.Scope, selection, nil, begin, end, metric, (page-1)*size, size)
}
+220
View File
@@ -0,0 +1,220 @@
package business
import (
"context"
"strconv"
"time"
"github.com/go-kratos/kratos/v3/errors"
)
var ErrGoalInput = errors.BadRequest("SW_GOAL_INPUT", "目标须为非负整数或留空,可修改近6个月及下月目标")
type GoalUsecase struct {
repo GoalRepo
performance *PerformanceUsecase
now func() time.Time
}
func (uc *GoalUsecase) Mine(ctx context.Context) (*GoalMonth, error) {
actor, _, err := uc.performance.teams.accounts.actor(ctx)
if err != nil {
return nil, err
}
now := uc.now().In(portraitLocation)
month := now.Format("2006-01")
goals, err := uc.repo.List(ctx, month, month)
if err != nil {
return nil, err
}
var goal *PerformanceGoal
for _, g := range goals {
if g.Kind == "person" && g.TargetID == uint64(actor.UserID) {
goal = g
break
}
}
if goal == nil {
return nil, nil
}
_, owners, _, _, err := uc.performance.Target(ctx, "person", uint64(actor.UserID))
if err != nil {
return nil, err
}
data, err := uc.performance.query(ctx, PerformanceFilter{Year: now.Year(), BeginMonth: int(now.Month()), EndMonth: int(now.Month())}, owners)
if err != nil {
return nil, err
}
if !data.Available {
return nil, ErrWorkbenchUnavailable
}
for _, row := range data.Rows {
if row.Member.ID == actor.UserID {
return &GoalMonth{Month: month, Goal: goal, Actual: row.Total, Available: true}, nil
}
}
return nil, nil
}
func NewGoalUsecase(repo GoalRepo, performance *PerformanceUsecase) *GoalUsecase {
return &GoalUsecase{repo: repo, performance: performance, now: time.Now}
}
func goalEditable(month string, now time.Time) bool {
current := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, portraitLocation)
return month >= current.AddDate(0, -5, 0).Format("2006-01") && month <= current.AddDate(0, 1, 0).Format("2006-01")
}
func (uc *GoalUsecase) Save(ctx context.Context, goal *PerformanceGoal) error {
if goal == nil {
return ErrGoalInput
}
_, _, _, editable, err := uc.performance.Target(ctx, goal.Kind, goal.TargetID)
if err != nil {
return err
}
if !editable {
return ErrTeamScope
}
month, err := time.ParseInLocation("2006-01", goal.Month, portraitLocation)
if err != nil || month.Format("2006-01") != goal.Month || !goalEditable(goal.Month, uc.now().In(portraitLocation)) {
return ErrGoalInput
}
for _, value := range []*int64{goal.Open, goal.Effective, goal.Amount} {
if value != nil && (*value < 0 || *value > MaxSettingsInteger) {
return ErrGoalInput
}
}
actor, _, err := uc.performance.teams.accounts.actor(ctx)
if err != nil {
return err
}
goal.UpdatedBy = actor.UserID
return uc.repo.Save(ctx, goal)
}
func goalStatus(goal *PerformanceGoal, actual PerformanceMetrics, month, current string) (string, int) {
if goal == nil || goal.Open == nil && goal.Effective == nil && goal.Amount == nil {
return "unset", 0
}
missing := 0
if goal.Open != nil && actual.Open < *goal.Open {
missing++
}
if goal.Effective != nil && actual.Effective < *goal.Effective {
missing++
}
if goal.Amount != nil {
pay, _ := portraitNumber(actual.Pay)
threshold, _ := portraitNumber(strconv.FormatInt(*goal.Amount, 10))
if pay.Cmp(threshold) < 0 {
missing++
}
}
if month == current {
return "ongoing", missing
}
if month > current {
return "future", missing
}
if missing == 0 {
return "achieved", 0
}
return "missing", missing
}
func (uc *GoalUsecase) History(ctx context.Context, kind string, id uint64) (*GoalPage, error) {
_, owners, name, editable, err := uc.performance.Target(ctx, kind, id)
if err != nil {
return nil, err
}
now := uc.now().In(portraitLocation)
current := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, portraitLocation)
begin := current.AddDate(0, -5, 0)
end := current.AddDate(0, 1, 0)
goals, err := uc.repo.List(ctx, begin.Format("2006-01"), end.Format("2006-01"))
if err != nil {
return nil, err
}
byMonth := map[string]*PerformanceGoal{}
for _, g := range goals {
if g.Kind == kind && g.TargetID == id {
byMonth[g.Month] = g
}
}
out := &GoalPage{Kind: kind, TargetID: id, Name: name, CanEdit: editable, Months: []GoalMonth{}, MemberGoals: map[string]PerformanceMetrics{}}
if kind == "group" {
view, err := uc.performance.teams.View(ctx)
if err != nil {
return nil, err
}
members := map[uint64]bool{}
for _, group := range view.Groups {
if group.ID == id {
for _, member := range group.MemberIDs {
members[uint64(member)] = true
}
}
}
for _, goal := range goals {
if goal.Kind != "person" || !members[goal.TargetID] {
continue
}
value := PerformanceMetrics{Pay: "0.00"}
if goal.Open != nil {
value.Open = *goal.Open
}
if goal.Effective != nil {
value.Effective = *goal.Effective
}
if goal.Amount != nil {
value.Pay = strconv.FormatInt(*goal.Amount, 10)
}
out.MemberGoals[goal.Month] = addPerformance(out.MemberGoals[goal.Month], value)
}
}
// At most two year queries supply the complete history; no query per row.
actuals := map[string]PerformanceMetrics{}
available := true
for year := begin.Year(); year <= end.Year(); year++ {
from, to := 1, 12
if year == begin.Year() {
from = int(begin.Month())
}
if year == end.Year() {
to = int(end.Month())
}
data, err := uc.performance.query(ctx, PerformanceFilter{Year: year, BeginMonth: from, EndMonth: to}, owners)
if err != nil {
return nil, err
}
available = available && data.Available
var values map[string]PerformanceMetrics
if kind == "person" {
for _, row := range data.Rows {
if uint64(row.Member.ID) == id {
values = row.Periods
}
}
} else {
for _, group := range data.Groups {
if group.Group.ID == id {
values = group.Periods
}
}
}
for key, value := range values {
month := key[:7]
actuals[month] = addPerformance(actuals[month], value)
}
}
for month := end; !month.Before(begin); month = month.AddDate(0, -1, 0) {
key := month.Format("2006-01")
actual := actuals[key]
if actual.Pay == "" {
actual.Pay = "0.00"
}
status, missing := goalStatus(byMonth[key], actual, key, current.Format("2006-01"))
if !available {
status = "unavailable"
}
out.Months = append(out.Months, GoalMonth{Month: key, Goal: byMonth[key], Actual: actual, Status: status, Missing: missing, CanEdit: editable && goalEditable(key, now), Available: available})
}
return out, nil
}
+139
View File
@@ -0,0 +1,139 @@
package business
import (
"context"
"time"
)
type PerformanceMetrics struct {
Open, Effective int64
Pay string
}
type PerformancePeriod struct{ Key, Label, Begin, End string }
type PerformanceFilter struct {
Year, BeginMonth, EndMonth int
Name string
Level *int
GroupID *uint64
SortField string
Ascending bool
ForManagement bool
}
type PerformanceRow struct {
Member *BusinessMember
Total PerformanceMetrics
Rate *string
Periods map[string]PerformanceMetrics
Goals map[string]*PerformanceGoal
DescendantCount int
}
type PerformanceGroupRow struct {
Group *BusinessGroup
Total, UniqueTotal PerformanceMetrics
Periods, UniquePeriods map[string]PerformanceMetrics
Members []*PerformanceRow
Goals map[string]*PerformanceGoal
}
type PerformancePage struct {
MatrixTotal PerformanceMetrics
MatrixRate *string
MatrixTrend map[string]PerformanceMetrics
Prior PerformanceMetrics
Rows []*PerformanceRow
Groups []*PerformanceGroupRow
Total PerformanceMetrics
Rate *string
ProductiveMembers int64
Periods []PerformancePeriod
Trend map[string]PerformanceMetrics
Begin, End, Granularity string
Settings OtherSettings
Available bool
}
type PerformanceChannel struct {
ChannelKey
ContributorID uint
ContributorName string
OwnerID uint
OwnerIDs []uint
Account, Name, OwnerName string
CreatedAt, QualifiedAt *int64
WindowPay, PeriodPay string
}
type PerformanceCash struct {
ChannelKey
OwnerIDs []uint
Date, Pay string
}
type PerformanceFacts struct {
Channels []*PerformanceChannel
Cash []PerformanceCash
Available bool
}
type PerformanceRepo interface {
Facts(context.Context, UserScope, []uint, []string, time.Time, time.Time) (*PerformanceFacts, error)
Details(context.Context, UserScope, PerformanceDetailSelection, []string, time.Time, time.Time, string, int, int) ([]*PerformanceChannel, int64, error)
}
// ContributorIDs requests one row per contributing member and channel.
// Without contributors the same channels are returned as a deduplicated union.
type PerformanceDetailSelection struct {
OwnerIDs []uint
ContributorIDs []uint
}
type PerformanceGoal struct {
Kind string
TargetID uint64
Month string
Open, Effective, Amount *int64
UpdatedBy uint
UpdatedAt time.Time
}
type GoalRepo interface {
List(context.Context, string, string) ([]*PerformanceGoal, error)
Save(context.Context, *PerformanceGoal) error
}
type GoalMonth struct {
Available bool
Month string
Goal *PerformanceGoal
Actual PerformanceMetrics
Status string
Missing int
CanEdit bool
}
type GoalPage struct {
Kind, Name string
TargetID uint64
CanEdit bool
Months []GoalMonth
MemberGoals map[string]PerformanceMetrics
}
type EffectivenessCandidate struct {
ChannelKey
CreatedAt, PreviousAt *int64
PreviousPay string
}
type EffectivenessCharge struct {
ChannelKey
PaidAt int64
Amount string
}
type EffectivenessResult struct {
ChannelKey
QualifiedAt *int64
WindowPay string
Complete bool
}
type EffectivenessState struct {
RuleKey string
Revision uint64
BuiltAt time.Time
}
type EffectivenessRepo interface {
State(context.Context) (*EffectivenessState, error)
Candidates(context.Context, string, int64, int64, bool) ([]*EffectivenessCandidate, error)
Charges(context.Context, []*EffectivenessCandidate, int64, int64, bool, func(EffectivenessCharge) error) error
Publish(context.Context, *EffectivenessState, OtherSettings, string, []EffectivenessResult, bool) (*OtherSettings, error)
}
+351
View File
@@ -0,0 +1,351 @@
package business
import (
"context"
"sort"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/go-kratos/kratos/v3/errors"
"golang.org/x/crypto/bcrypt"
)
var (
ErrTeamInput = errors.BadRequest("SW_TEAM_INPUT", "小组名称须为1至10个字,每位商务只能加入一个小组")
ErrTeamScope = errors.Forbidden("SW_TEAM_SCOPE", "无权管理该小组或成员")
ErrTeamConflict = errors.Conflict("SW_TEAM_CONFLICT", "小组配置已被其他人修改,请刷新后重试")
ErrMemberInput = errors.BadRequest("SW_MEMBER_INPUT", "请填写有效的姓名、联系电话、入职日期和在职状态")
)
type BusinessMember struct {
Account
GroupID uint64
GroupName string
CanEdit, CanSetGoal bool
}
type BusinessGroup struct {
ID uint64
Name string
OwnerID uint
Sort int
MemberIDs []uint
CanEdit, CanSetGoal, Mine, Partial, Overlapping bool
Relation string
ChannelCount int64
}
type TeamCatalog struct {
Revision uint64
Groups []*BusinessGroup
}
type TeamView struct {
Scope UserScope
Revision uint64
Groups []*BusinessGroup
Members []*BusinessMember
CanManage bool
ViewerID uint
}
type MemberProfile struct {
ID uint
RealName, Phone, HireDate, Employment, Password string
}
type TeamRepo interface {
Members(context.Context, UserScope) ([]*BusinessMember, error)
Catalog(context.Context) (*TeamCatalog, error)
SaveCatalog(context.Context, uint64, []*BusinessGroup) (*TeamCatalog, error)
UpdateProfile(context.Context, MemberProfile) error
}
type TeamUsecase struct {
repo TeamRepo
accounts *AccountUsecase
}
func NewTeamUsecase(repo TeamRepo, accounts *AccountUsecase) *TeamUsecase {
return &TeamUsecase{repo: repo, accounts: accounts}
}
func memberName(member *BusinessMember) string {
if strings.TrimSpace(member.NickName) != "" {
return member.NickName
}
return member.Username
}
// Business depth is bounded; derive ancestors from IDs instead of comparing
// every member with every other member on each statistics request.
func memberAncestors(member *BusinessMember, byID map[uint]*BusinessMember) []uint {
ids := []uint{member.ID}
seen := map[uint]bool{member.ID: true}
add := func(id uint) {
if !seen[id] && byID[id] != nil && descendantOf(&byID[id].Account, &member.Account) {
seen[id] = true
ids = append(ids, id)
}
}
add(member.ParentID)
for _, part := range strings.Split(member.IDPath, ",") {
id, err := strconv.ParseUint(part, 10, 64)
if err == nil {
add(uint(id))
}
}
return ids
}
func organizeMembers(members []*BusinessMember) {
byID := map[uint]*BusinessMember{}
children := map[uint][]*BusinessMember{}
for _, m := range members {
byID[m.ID] = m
children[m.ParentID] = append(children[m.ParentID], m)
}
for _, list := range children {
sort.SliceStable(list, func(i, j int) bool { return list[i].ID < list[j].ID })
}
roots := []*BusinessMember{}
for _, m := range members {
if byID[m.ParentID] == nil {
roots = append(roots, m)
}
}
sort.SliceStable(roots, func(i, j int) bool { return roots[i].ID < roots[j].ID })
ordered := make([]*BusinessMember, 0, len(members))
seen := map[uint]bool{}
var visit func(*BusinessMember)
visit = func(m *BusinessMember) {
if seen[m.ID] {
return
}
seen[m.ID] = true
ordered = append(ordered, m)
for _, c := range children[m.ID] {
visit(c)
}
}
for _, m := range roots {
visit(m)
}
for _, m := range members {
visit(m)
}
copy(members, ordered)
}
func (uc *TeamUsecase) View(ctx context.Context) (*TeamView, error) {
actor, account, err := uc.accounts.actor(ctx)
if err != nil {
return nil, err
}
scope := portraitUserScope(account)
scope.All = actor.AuthorityID == 888
members, err := uc.repo.Members(ctx, scope)
if err != nil {
return nil, err
}
organizeMembers(members)
catalog, err := uc.repo.Catalog(ctx)
if err != nil {
return nil, err
}
view := &TeamView{Revision: catalog.Revision, Groups: []*BusinessGroup{}, Members: members, CanManage: scope.All || account.Level == 1, ViewerID: account.ID, Scope: scope}
visible := map[uint]*BusinessMember{}
order := map[uint]int{}
for i, m := range members {
m.GroupID, m.GroupName = 0, ""
visible[m.ID] = m
order[m.ID] = i
m.CanEdit = scope.All || m.ID == account.ID || descendantOf(account, &m.Account)
m.CanSetGoal = scope.All || (account.Level == 1 && descendantOf(account, &m.Account))
}
for _, g := range catalog.Groups {
group := *g
group.MemberIDs = []uint{}
for _, id := range g.MemberIDs {
if m := visible[id]; m != nil {
group.MemberIDs = append(group.MemberIDs, id)
m.GroupID = g.ID
m.GroupName = g.Name
if id == account.ID {
group.Mine = true
}
if m.PromoteCount != nil {
group.ChannelCount += *m.PromoteCount
}
}
}
if len(group.MemberIDs) == 0 && !scope.All && visible[g.OwnerID] == nil {
continue
}
group.Partial = len(group.MemberIDs) != len(g.MemberIDs)
group.CanEdit = scope.All || view.CanManage && !group.Partial
group.CanSetGoal = view.CanManage && len(group.MemberIDs) > 0
if group.Mine {
group.Relation = "本组"
} else if len(group.MemberIDs) > 0 {
nearest := visible[group.MemberIDs[0]]
for _, id := range group.MemberIDs {
m := visible[id]
if m.Level < nearest.Level || (m.Level == nearest.Level && order[m.ID] < order[nearest.ID]) {
nearest = m
}
}
group.Relation = "下级 · " + memberName(nearest)
}
memberIDs := map[uint]bool{}
for _, id := range group.MemberIDs {
memberIDs[id] = true
}
for _, id := range group.MemberIDs {
for _, ancestor := range memberAncestors(visible[id], visible) {
if ancestor != id && memberIDs[ancestor] {
group.Overlapping = true
}
}
}
view.Groups = append(view.Groups, &group)
}
return view, nil
}
func (uc *TeamUsecase) Save(ctx context.Context, revision uint64, input []*BusinessGroup) (*TeamView, error) {
view, err := uc.View(ctx)
if err != nil {
return nil, err
}
if !view.CanManage {
return nil, ErrTeamScope
}
if revision != view.Revision {
return nil, ErrTeamConflict
}
all, err := uc.repo.Catalog(ctx)
if err != nil {
return nil, err
}
if all.Revision != revision {
return nil, ErrTeamConflict
}
maxSort := 0
for _, g := range all.Groups {
if g.Sort >= maxSort {
maxSort = g.Sort + 1
}
}
visible := map[uint]bool{}
editable := map[uint64]*BusinessGroup{}
previous := map[uint64]*BusinessGroup{}
for _, m := range view.Members {
visible[m.ID] = true
}
for _, g := range view.Groups {
editable[g.ID] = g
}
for _, g := range all.Groups {
previous[g.ID] = g
}
if len(input) > 500 {
return nil, ErrTeamInput
}
seenGroups := map[uint64]bool{}
seenMembers := map[uint]bool{}
next := []*BusinessGroup{}
for index, g := range input {
if g == nil {
return nil, ErrTeamInput
}
name := strings.TrimSpace(g.Name)
if utf8.RuneCountInString(name) < 1 || utf8.RuneCountInString(name) > 10 {
return nil, ErrTeamInput
}
copy := &BusinessGroup{ID: g.ID, Name: name, OwnerID: view.ViewerID, Sort: index, MemberIDs: []uint{}}
if !view.Scope.All {
copy.Sort = maxSort
maxSort++
}
if g.ID != 0 {
old := previous[g.ID]
permission := editable[g.ID]
if old == nil || permission == nil || seenGroups[g.ID] {
return nil, ErrTeamScope
}
if !permission.CanEdit && name != old.Name {
return nil, ErrTeamScope
}
seenGroups[g.ID] = true
copy.OwnerID = old.OwnerID
if !view.Scope.All {
copy.Sort = old.Sort
}
for _, id := range old.MemberIDs {
if !visible[id] {
copy.MemberIDs = append(copy.MemberIDs, id)
}
}
}
for _, id := range g.MemberIDs {
if !visible[id] {
return nil, ErrTeamScope
}
if seenMembers[id] {
return nil, ErrTeamInput
}
seenMembers[id] = true
copy.MemberIDs = append(copy.MemberIDs, id)
}
next = append(next, copy)
}
for _, old := range all.Groups {
if seenGroups[old.ID] {
continue
}
if permission := editable[old.ID]; permission != nil {
if !permission.CanEdit {
return nil, ErrTeamScope
}
continue
}
copy := *old
copy.MemberIDs = append([]uint{}, old.MemberIDs...)
next = append(next, &copy)
}
if _, err := uc.repo.SaveCatalog(ctx, revision, next); err != nil {
return nil, err
}
return uc.View(ctx)
}
func (uc *TeamUsecase) UpdateProfile(ctx context.Context, profile MemberProfile) error {
view, err := uc.View(ctx)
if err != nil {
return err
}
var member *BusinessMember
for _, m := range view.Members {
if m.ID == profile.ID {
member = m
break
}
}
if member == nil || !member.CanEdit {
return ErrTeamScope
}
if profile.Password != "" {
hash, err := bcrypt.GenerateFromPassword([]byte(profile.Password), bcrypt.DefaultCost)
if err != nil {
return ErrMemberInput
}
profile.Password = string(hash)
}
return uc.repo.UpdateProfile(ctx, profile)
}
func DefaultPerformanceRange(now time.Time) (time.Time, time.Time) {
now = now.In(portraitLocation)
begin := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, portraitLocation)
end := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, portraitLocation).AddDate(0, 0, -1)
return begin, end
}