595 lines
14 KiB
Go
595 lines
14 KiB
Go
package sprints
|
||
|
||
import (
|
||
"context"
|
||
"git.aiterp.net/stufflog3/stufflog3/entities"
|
||
"git.aiterp.net/stufflog3/stufflog3/internal/genutils"
|
||
"git.aiterp.net/stufflog3/stufflog3/models"
|
||
"git.aiterp.net/stufflog3/stufflog3/usecases/items"
|
||
"git.aiterp.net/stufflog3/stufflog3/usecases/projects"
|
||
"git.aiterp.net/stufflog3/stufflog3/usecases/scopes"
|
||
"golang.org/x/sync/errgroup"
|
||
"math"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
type Service struct {
|
||
Scopes *scopes.Service
|
||
Items *items.Service
|
||
Projects *projects.Service
|
||
|
||
Repository Repository
|
||
}
|
||
|
||
func (s *Service) Find(ctx context.Context, id int) (*Result, error) {
|
||
sprint, err := s.Repository.Find(ctx, s.Scopes.Context(ctx).ID, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
parts, err := s.Repository.ListParts(ctx, *sprint)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return s.fill(ctx, *sprint, parts)
|
||
}
|
||
|
||
func (s *Service) ListBetween(ctx context.Context, from, to time.Time) ([]Result, error) {
|
||
allScopes, err := s.Scopes.Context(ctx).Scopes(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
allSprints := make([][]entities.Sprint, len(allScopes))
|
||
|
||
eg, ctx2 := errgroup.WithContext(ctx)
|
||
for i, scope := range allScopes {
|
||
list := &allSprints[i]
|
||
scopeID := scope.ID
|
||
|
||
eg.Go(func() (err error) {
|
||
*list, err = s.Repository.ListBetween(ctx2, scopeID, from, to)
|
||
return
|
||
})
|
||
}
|
||
err = eg.Wait()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
sprints := genutils.Flatten2(allSprints)
|
||
sort.Slice(sprints, func(i, j int) bool {
|
||
si := sprints[i]
|
||
sj := sprints[j]
|
||
|
||
if si.FromTime.Equal(sj.FromTime) {
|
||
return si.Name < sj.Name
|
||
}
|
||
|
||
return si.FromTime.Before(sj.FromTime)
|
||
})
|
||
|
||
return s.fillMany(ctx, sprints)
|
||
}
|
||
|
||
func (s *Service) ListScopedBetween(ctx context.Context, from, to time.Time) ([]Result, error) {
|
||
sprints, err := s.Repository.ListBetween(ctx, s.Scopes.Context(ctx).ID, from, to)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return s.fillMany(ctx, sprints)
|
||
}
|
||
|
||
func (s *Service) fillMany(ctx context.Context, sprints []entities.Sprint) ([]Result, error) {
|
||
sort.Slice(sprints, func(i, j int) bool {
|
||
si := sprints[i]
|
||
sj := sprints[j]
|
||
|
||
if si.IsTimed != sj.IsTimed {
|
||
return si.IsTimed
|
||
}
|
||
|
||
if !si.FromTime.Equal(sj.FromTime) {
|
||
return si.FromTime.Before(sj.FromTime)
|
||
}
|
||
|
||
return si.ID < sj.ID
|
||
})
|
||
|
||
parts, err := s.Repository.ListParts(ctx, sprints...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
eg, ctx2 := errgroup.WithContext(ctx)
|
||
|
||
results := make([]Result, len(sprints))
|
||
for i := range sprints {
|
||
iCopy := i
|
||
|
||
eg.Go(func() error {
|
||
res, err := s.fill(ctx2, sprints[iCopy], parts)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
results[iCopy] = *res
|
||
|
||
return nil
|
||
})
|
||
}
|
||
|
||
err = eg.Wait()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return results, nil
|
||
}
|
||
|
||
func (s *Service) Create(ctx context.Context, sprint entities.Sprint, parts []entities.SprintPart) (*Result, error) {
|
||
scope := s.Scopes.Context(ctx).Scope
|
||
|
||
sprint.Name = strings.Trim(sprint.Name, " \t\r\n")
|
||
if sprint.Name == "" {
|
||
return nil, models.BadInputError{
|
||
Object: "SprintInput",
|
||
Field: "name",
|
||
Problem: "Empty name provided",
|
||
}
|
||
}
|
||
if !sprint.Kind.Valid() {
|
||
return nil, models.BadInputError{
|
||
Object: "SprintInput",
|
||
Field: "kind",
|
||
Problem: "Non-existent kind ID",
|
||
Min: 0,
|
||
Max: models.MaxSprintKind,
|
||
}
|
||
}
|
||
if sprint.FromTime.IsZero() {
|
||
return nil, models.BadInputError{
|
||
Object: "SprintInput",
|
||
Field: "fromTime",
|
||
Problem: "Empty from time provided",
|
||
}
|
||
}
|
||
if sprint.ToTime.IsZero() {
|
||
return nil, models.BadInputError{
|
||
Object: "SprintInput",
|
||
Field: "toTime",
|
||
Problem: "Empty from time provided",
|
||
}
|
||
}
|
||
if sprint.ToTime.Before(sprint.FromTime) {
|
||
return nil, models.BadInputError{
|
||
Object: "SprintInput",
|
||
Field: "toTime",
|
||
Problem: "Time goes only one way in this universe as fas as we know",
|
||
}
|
||
}
|
||
|
||
ids := genutils.Map(parts, func(p entities.SprintPart) int { return p.PartID })
|
||
|
||
switch sprint.Kind {
|
||
case models.SprintKindScope:
|
||
{
|
||
if len(parts) > 0 {
|
||
return nil, models.BadInputError{
|
||
Object: "SprintInput",
|
||
Field: "parts",
|
||
Problem: "Scope-sprints cannot have parts.",
|
||
}
|
||
}
|
||
}
|
||
case models.SprintKindStats:
|
||
{
|
||
for _, part := range parts {
|
||
if !scope.HasStat(part.PartID) {
|
||
return nil, models.NotFoundIDError("Stat", part.PartID)
|
||
}
|
||
}
|
||
}
|
||
case models.SprintKindItems:
|
||
{
|
||
sprintItems, err := s.Items.ListScoped(ctx, models.ItemFilter{IDs: ids})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if len(sprintItems) != len(ids) {
|
||
for _, id := range ids {
|
||
if genutils.Find(sprintItems, func(i items.Result) bool { return i.ID == id }) == nil {
|
||
return nil, models.NotFoundIDError("Item", id)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
case models.SprintKindRequirements:
|
||
{
|
||
sprintReqs, err := s.Projects.FetchRequirements(ctx, ids...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if len(sprintReqs) != len(ids) {
|
||
for _, id := range ids {
|
||
if genutils.Find(sprintReqs, func(i projects.RequirementResult) bool { return i.ID == id }) == nil {
|
||
return nil, models.NotFoundIDError("Requirement", id)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
sprint.ScopeID = scope.ID
|
||
|
||
newSprint, err := s.Repository.Insert(ctx, sprint)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
newParts := make([]entities.SprintPart, 0, 16)
|
||
for _, part := range parts {
|
||
part.SprintID = newSprint.ID
|
||
err = s.Repository.UpdatePart(ctx, part)
|
||
if err == nil {
|
||
newParts = append(newParts, part)
|
||
}
|
||
}
|
||
|
||
return s.fill(ctx, *newSprint, newParts)
|
||
}
|
||
|
||
func (s *Service) Update(ctx context.Context, id int, update models.SprintUpdate) (*Result, error) {
|
||
scope := s.Scopes.Context(ctx).Scope
|
||
sprint, err := s.Repository.Find(ctx, scope.ID, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if update.Name != nil && *update.Name == "" {
|
||
return nil, models.BadInputError{
|
||
Object: "SprintUpdate",
|
||
Field: "name",
|
||
Problem: "Empty name provided",
|
||
}
|
||
}
|
||
fromTime := sprint.FromTime
|
||
if update.FromTime != nil {
|
||
fromTime = *update.FromTime
|
||
}
|
||
if update.ToTime != nil && update.ToTime.Before(fromTime) {
|
||
return nil, models.BadInputError{
|
||
Object: "SprintUpdate",
|
||
Field: "toTime",
|
||
Problem: "Time goes only one way in this universe as fas as we know",
|
||
}
|
||
}
|
||
|
||
err = s.Repository.Update(ctx, *sprint, update)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
sprint.ApplyUpdate(update)
|
||
parts, err := s.Repository.ListParts(ctx, *sprint)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return s.fill(ctx, *sprint, parts)
|
||
}
|
||
|
||
func (s *Service) AddPart(ctx context.Context, sprintPart entities.SprintPart) (*Result, error) {
|
||
scope := s.Scopes.Context(ctx).Scope
|
||
sprint, err := s.Repository.Find(ctx, scope.ID, sprintPart.SprintID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
switch sprint.Kind {
|
||
case models.SprintKindItems:
|
||
{
|
||
_, err := s.Items.Find(ctx, sprintPart.PartID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
case models.SprintKindRequirements:
|
||
{
|
||
_, err := s.Projects.FindRequirement(ctx, sprintPart.PartID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
case models.SprintKindStats:
|
||
{
|
||
if !scope.HasStat(sprintPart.PartID) {
|
||
return nil, models.NotFoundIDError("Stat", sprintPart.PartID)
|
||
}
|
||
}
|
||
case models.SprintKindScope:
|
||
{
|
||
return nil, models.ForbiddenError("Scope-sprints cannot have parts")
|
||
}
|
||
}
|
||
|
||
err = s.Repository.UpdatePart(ctx, sprintPart)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
sprintParts, err := s.Repository.ListParts(ctx, *sprint)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return s.fill(ctx, *sprint, sprintParts)
|
||
}
|
||
|
||
func (s *Service) RemovePart(ctx context.Context, sprintPart entities.SprintPart) (*Result, error) {
|
||
scope := s.Scopes.Context(ctx).Scope
|
||
sprint, err := s.Repository.Find(ctx, scope.ID, sprintPart.SprintID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
err = s.Repository.DeletePart(ctx, sprintPart)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
sprintParts, err := s.Repository.ListParts(ctx, *sprint)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return s.fill(ctx, *sprint, sprintParts)
|
||
}
|
||
|
||
func (s *Service) Delete(ctx context.Context, id int) (*Result, error) {
|
||
sprint, err := s.Find(ctx, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
err = s.Repository.Delete(ctx, sprint.Sprint)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return sprint, nil
|
||
}
|
||
|
||
func (s *Service) fill(ctx context.Context, sprint entities.Sprint, parts []entities.SprintPart) (*Result, error) {
|
||
res := Result{Sprint: sprint}
|
||
|
||
var allStats []scopes.ResultStat
|
||
sc := s.Scopes.Context(ctx)
|
||
if sc.ID != sprint.ScopeID {
|
||
allScopes, err := sc.Scopes(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
for _, scope := range allScopes {
|
||
if scope.ID == sprint.ScopeID {
|
||
allStats = scope.Stats
|
||
break
|
||
}
|
||
}
|
||
if allStats == nil {
|
||
return nil, models.NotFoundError("Scope")
|
||
}
|
||
} else {
|
||
allStats = sc.Scope.Stats
|
||
}
|
||
|
||
partIDs := make([]int, 0, len(parts))
|
||
for _, part := range parts {
|
||
if part.SprintID == sprint.ID {
|
||
partIDs = append(partIDs, part.PartID)
|
||
}
|
||
}
|
||
|
||
progressMap := make(map[int]int, len(allStats))
|
||
res.Progress = make([]ResultProgress, 0, len(allStats))
|
||
res.PartIDs = partIDs
|
||
|
||
switch res.Kind {
|
||
case models.SprintKindItems:
|
||
pickedItems, err := s.Items.ListScoped(ctx, models.ItemFilter{
|
||
IDs: partIDs,
|
||
ScopeIDs: []int{sprint.ScopeID},
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
res.Items = pickedItems
|
||
|
||
res.ItemsRequired = genutils.Ptr(len(pickedItems))
|
||
res.ItemsAcquired = new(int)
|
||
for _, item := range pickedItems {
|
||
if item.AcquiredTime != nil {
|
||
*res.ItemsAcquired += 1
|
||
}
|
||
}
|
||
|
||
for _, stat := range allStats {
|
||
totalRequired := 0
|
||
for _, item := range res.Items {
|
||
if item.AcquiredOutside(sprint.FromTime, sprint.ToTime) {
|
||
continue
|
||
}
|
||
|
||
if itemStat := item.Stat(stat.ID); itemStat != nil {
|
||
totalRequired += itemStat.Required
|
||
}
|
||
}
|
||
if totalRequired == 0 {
|
||
continue
|
||
}
|
||
|
||
progressMap[stat.ID] = len(res.Progress)
|
||
res.Progress = append(res.Progress, ResultProgress{
|
||
ID: stat.ID,
|
||
Name: stat.Name,
|
||
Weight: stat.Weight,
|
||
Required: &totalRequired,
|
||
})
|
||
}
|
||
case models.SprintKindRequirements:
|
||
includedRequirements, err := s.Projects.FetchRequirements(ctx, partIDs...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
res.Requirements = includedRequirements
|
||
|
||
for _, req := range res.Requirements {
|
||
res.Items = append(res.Items, req.Items...)
|
||
}
|
||
|
||
for _, stat := range allStats {
|
||
totalRequired := 0
|
||
for _, req := range res.Requirements {
|
||
if reqStat := req.Stat(stat.ID); reqStat != nil {
|
||
totalRequired += reqStat.Required
|
||
}
|
||
}
|
||
if totalRequired == 0 {
|
||
continue
|
||
}
|
||
|
||
progressMap[stat.ID] = len(res.Progress)
|
||
res.Progress = append(res.Progress, ResultProgress{
|
||
ID: stat.ID,
|
||
Name: stat.Name,
|
||
Weight: stat.Weight,
|
||
Required: &totalRequired,
|
||
})
|
||
}
|
||
|
||
case models.SprintKindStats:
|
||
includedStats := make([]scopes.ResultStat, 0, len(partIDs))
|
||
for _, stat := range allStats {
|
||
for _, id := range partIDs {
|
||
if stat.ID == id {
|
||
includedStats = append(includedStats, stat)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
progressMap = make(map[int]int, len(includedStats))
|
||
res.Progress = make([]ResultProgress, 0, len(includedStats))
|
||
for _, stat := range includedStats {
|
||
required := 0
|
||
for _, part := range parts {
|
||
if part.PartID == stat.ID {
|
||
required = part.Required
|
||
break
|
||
}
|
||
}
|
||
|
||
progressMap[stat.ID] = len(res.Progress)
|
||
res.Progress = append(res.Progress, ResultProgress{
|
||
ID: stat.ID,
|
||
Name: stat.Name,
|
||
Weight: stat.Weight,
|
||
Required: &required,
|
||
})
|
||
}
|
||
|
||
acquiredItems, err := s.Items.ListScoped(ctx, models.ItemFilter{
|
||
AcquiredTime: &models.TimeInterval[time.Time]{Min: sprint.FromTime, Max: sprint.ToTime},
|
||
StatIDs: partIDs,
|
||
ScopeIDs: []int{sprint.ScopeID},
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
res.Items = acquiredItems
|
||
|
||
case models.SprintKindScope:
|
||
acquiredItems, err := s.Items.ListScoped(ctx, models.ItemFilter{
|
||
AcquiredTime: &models.TimeInterval[time.Time]{Min: sprint.FromTime, Max: sprint.ToTime},
|
||
ScopeIDs: []int{sprint.ScopeID},
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
res.Items = acquiredItems
|
||
|
||
for _, stat := range allStats {
|
||
progressMap[stat.ID] = len(res.Progress)
|
||
res.Progress = append(res.Progress, ResultProgress{
|
||
ID: stat.ID,
|
||
Name: stat.Name,
|
||
Weight: stat.Weight,
|
||
Required: genutils.Ptr(0),
|
||
})
|
||
}
|
||
}
|
||
|
||
sort.Slice(res.Items, func(i, j int) bool {
|
||
ii := res.Items[i]
|
||
ij := res.Items[j]
|
||
|
||
if ii.AcquiredTime == nil && ij.AcquiredTime == nil {
|
||
return res.Items[i].CreatedTime.Before(res.Items[j].CreatedTime)
|
||
} else if ii.AcquiredTime == nil && ij.AcquiredTime != nil {
|
||
return true
|
||
} else if ii.AcquiredTime != nil && ij.AcquiredTime == nil {
|
||
return false
|
||
} else {
|
||
return ii.AcquiredTime.Before(*ij.AcquiredTime)
|
||
}
|
||
})
|
||
|
||
// Measure progress
|
||
for _, item := range res.Items {
|
||
if !item.AcquiredBetween(sprint.FromTime, sprint.ToTime) {
|
||
continue
|
||
}
|
||
|
||
for _, stat := range item.Stats {
|
||
if pi, ok := progressMap[stat.ID]; ok {
|
||
res.Progress[pi].Acquired += stat.Acquired
|
||
}
|
||
}
|
||
}
|
||
|
||
// For requirement sprints, remove items array after enumeration
|
||
if res.Kind == models.SprintKindRequirements {
|
||
res.Items = nil
|
||
}
|
||
|
||
// Calculate aggregate values
|
||
aggregateAcquired := 0.0
|
||
aggregatePlanned := 0.0
|
||
for _, progress := range res.Progress {
|
||
if res.IsUnweighted {
|
||
aggregateAcquired += float64(progress.Acquired)
|
||
if progress.Required != nil {
|
||
aggregatePlanned += float64(*progress.Required)
|
||
}
|
||
} else {
|
||
aggregateAcquired += float64(progress.Acquired) * progress.Weight
|
||
if progress.Required != nil {
|
||
aggregatePlanned += float64(*progress.Required) * progress.Weight
|
||
}
|
||
}
|
||
}
|
||
res.AggregateAcquired = int(math.Round(aggregateAcquired))
|
||
res.AggregatePlanned = int(math.Round(aggregatePlanned))
|
||
|
||
if res.AggregateRequired < 0 {
|
||
res.AggregateRequired = res.AggregatePlanned
|
||
}
|
||
|
||
return &res, nil
|
||
}
|