537 lines
16 KiB
Go
537 lines
16 KiB
Go
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
|
|
}
|
|
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)
|
|
}
|
|
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)
|
|
// The matrix is the union of the displayed members' descendant scopes;
|
|
// overlapping parent/child rows must not count the same channel twice.
|
|
for _, id := range a.members {
|
|
if member := byID[id]; member != nil && matrixVisible(member) {
|
|
matrix.add(key, metric, pay)
|
|
break
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
goalEnd := end.Format("2006-01")
|
|
if f.ForManagement {
|
|
// The business list shows historical performance and this month's goal.
|
|
goalEnd = snapshotTime.Format("2006-01")
|
|
}
|
|
goals, err := uc.goals.List(ctx, begin.Format("2006-01"), goalEnd)
|
|
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)
|
|
}
|