feat: 完善小组绩效与有效端统计并修复审查问题
This commit is contained in:
+1
-1
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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, ©)
|
||||
}
|
||||
}
|
||||
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("重算失败不能发布部分结果或修改规则")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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, ©)
|
||||
}
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user