|
|
@ -3,12 +3,14 @@ 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" |
|
|
|
"strings" |
|
|
|
"time" |
|
|
|
) |
|
|
|
|
|
|
@ -71,6 +73,239 @@ func (s *Service) ListScopedBetween(ctx context.Context, from, to time.Time) ([] |
|
|
|
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} |
|
|
|
sc := s.Scopes.Context(ctx) |
|
|
@ -85,6 +320,7 @@ func (s *Service) fill(ctx context.Context, sprint entities.Sprint, parts []enti |
|
|
|
|
|
|
|
progressMap := make(map[int]int, len(allStats)) |
|
|
|
res.Progress = make([]ResultProgress, 0, len(allStats)) |
|
|
|
res.PartIDs = partIDs |
|
|
|
|
|
|
|
switch res.Kind { |
|
|
|
case models.SprintKindItems: |
|
|
@ -96,6 +332,14 @@ func (s *Service) fill(ctx context.Context, sprint entities.Sprint, parts []enti |
|
|
|
} |
|
|
|
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 { |
|
|
@ -120,8 +364,9 @@ func (s *Service) fill(ctx context.Context, sprint entities.Sprint, parts []enti |
|
|
|
if err != nil { |
|
|
|
return nil, err |
|
|
|
} |
|
|
|
res.Requirements = includedRequirements |
|
|
|
|
|
|
|
for _, req := range includedRequirements { |
|
|
|
for _, req := range res.Requirements { |
|
|
|
res.Items = append(res.Items, req.Items...) |
|
|
|
} |
|
|
|
|
|
|
@ -145,7 +390,6 @@ func (s *Service) fill(ctx context.Context, sprint entities.Sprint, parts []enti |
|
|
|
}) |
|
|
|
} |
|
|
|
|
|
|
|
res.Requirements = includedRequirements |
|
|
|
case models.SprintKindStats: |
|
|
|
includedStats := make([]scopes.ResultStat, 0, len(partIDs)) |
|
|
|
for _, stat := range allStats { |
|
|
@ -225,22 +469,22 @@ func (s *Service) fill(ctx context.Context, sprint entities.Sprint, parts []enti |
|
|
|
|
|
|
|
// Calculate aggregate values
|
|
|
|
aggregateAcquired := 0.0 |
|
|
|
aggregateTotal := 0.0 |
|
|
|
aggregatePlanned := 0.0 |
|
|
|
for _, progress := range res.Progress { |
|
|
|
if res.IsUnweighted { |
|
|
|
aggregateAcquired += float64(progress.Acquired) |
|
|
|
if progress.Required != nil { |
|
|
|
aggregateTotal += float64(*progress.Required) |
|
|
|
aggregatePlanned += float64(*progress.Required) |
|
|
|
} |
|
|
|
} else { |
|
|
|
aggregateAcquired += float64(progress.Acquired) * progress.Weight |
|
|
|
if progress.Required != nil { |
|
|
|
aggregateTotal += float64(*progress.Required) * progress.Weight |
|
|
|
aggregatePlanned += float64(*progress.Required) * progress.Weight |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
res.AggregateAcquired = int(math.Round(aggregateAcquired)) |
|
|
|
res.AggregateTotal = int(math.Round(aggregateTotal)) |
|
|
|
res.AggregatePlanned = int(math.Round(aggregatePlanned)) |
|
|
|
|
|
|
|
return &res, nil |
|
|
|
} |