Files
stufflog3/usecases/sprints/service.go
T

595 lines
14 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}