feat: add party-size queueing and call modes
This commit is contained in:
47
server/internal/domain/call_selection.go
Normal file
47
server/internal/domain/call_selection.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"calllinesystem/server/internal/model"
|
||||
)
|
||||
|
||||
var ErrFirstTicketExceedsPeopleTarget = errors.New("first ticket party size exceeds people target")
|
||||
|
||||
// SelectTicketsForCall preserves the incoming FIFO order. Ticket mode selects
|
||||
// up to target tickets. People mode selects the longest leading sequence whose
|
||||
// total party size does not exceed target; tickets are never split or skipped.
|
||||
func SelectTicketsForCall(tickets []model.QueueTicket, mode string, target int) ([]model.QueueTicket, int, error) {
|
||||
if target < 1 || len(tickets) == 0 {
|
||||
return nil, 0, nil
|
||||
}
|
||||
if mode == model.CallModeTicket {
|
||||
count := min(target, len(tickets))
|
||||
selected := tickets[:count]
|
||||
return selected, totalPartySize(selected), nil
|
||||
}
|
||||
if mode != model.CallModePeople {
|
||||
return nil, 0, errors.New("unsupported call mode")
|
||||
}
|
||||
total := 0
|
||||
count := 0
|
||||
for _, ticket := range tickets {
|
||||
if total+ticket.PartySize > target {
|
||||
break
|
||||
}
|
||||
total += ticket.PartySize
|
||||
count++
|
||||
}
|
||||
if count == 0 {
|
||||
return nil, 0, ErrFirstTicketExceedsPeopleTarget
|
||||
}
|
||||
return tickets[:count], total, nil
|
||||
}
|
||||
|
||||
func totalPartySize(tickets []model.QueueTicket) int {
|
||||
total := 0
|
||||
for _, ticket := range tickets {
|
||||
total += ticket.PartySize
|
||||
}
|
||||
return total
|
||||
}
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
"calllinesystem/server/internal/model"
|
||||
)
|
||||
|
||||
func TestETAUsesIntervalForEveryNumber(t *testing.T) {
|
||||
func TestETAUsesIntervalForEveryPersonAhead(t *testing.T) {
|
||||
result, err := CalculateETA(ETAInput{
|
||||
PeopleAhead: 9, IntervalPerNumber: 90 * time.Second, Running: true,
|
||||
PeopleAhead: 9, IntervalPerPerson: 90 * time.Second, Running: true,
|
||||
})
|
||||
if err != nil || !result.Available {
|
||||
t.Fatalf("got %#v, %v", result, err)
|
||||
@@ -19,6 +19,32 @@ func TestETAUsesIntervalForEveryNumber(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestETAExcludesCurrentParty(t *testing.T) {
|
||||
result, err := CalculateETA(ETAInput{PeopleAhead: 0, IntervalPerPerson: 90 * time.Second, Running: true})
|
||||
if err != nil || !result.Available || result.EstimateMinutes != 0 {
|
||||
t.Fatalf("got %#v, %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTicketsForCall(t *testing.T) {
|
||||
tickets := []model.QueueTicket{
|
||||
{ID: "one", PartySize: 3},
|
||||
{ID: "two", PartySize: 4},
|
||||
{ID: "three", PartySize: 2},
|
||||
}
|
||||
byTicket, people, err := SelectTicketsForCall(tickets, model.CallModeTicket, 2)
|
||||
if err != nil || len(byTicket) != 2 || people != 7 {
|
||||
t.Fatalf("ticket selection = %#v, %d, %v", byTicket, people, err)
|
||||
}
|
||||
byPeople, people, err := SelectTicketsForCall(tickets, model.CallModePeople, 5)
|
||||
if err != nil || len(byPeople) != 1 || people != 3 || byPeople[0].ID != "one" {
|
||||
t.Fatalf("people selection = %#v, %d, %v", byPeople, people, err)
|
||||
}
|
||||
if _, _, err := SelectTicketsForCall(tickets, model.CallModePeople, 2); err != ErrFirstTicketExceedsPeopleTarget {
|
||||
t.Fatalf("expected first-ticket overflow, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestETAUnavailableWhenPaused(t *testing.T) {
|
||||
result, err := CalculateETA(ETAInput{Running: false})
|
||||
if err != nil || result.Available || result.Reason != "queue_not_running" {
|
||||
@@ -26,6 +52,26 @@ func TestETAUnavailableWhenPaused(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayExperiencedPeopleUsesConfiguredStartUntilActualCountExceedsIt(t *testing.T) {
|
||||
tests := []struct {
|
||||
start int
|
||||
actual int64
|
||||
want int64
|
||||
}{
|
||||
{start: 0, actual: 0, want: 0},
|
||||
{start: 20, actual: 0, want: 20},
|
||||
{start: 20, actual: 19, want: 20},
|
||||
{start: 20, actual: 20, want: 20},
|
||||
{start: 20, actual: 21, want: 21},
|
||||
{start: -1, actual: -3, want: 0},
|
||||
}
|
||||
for _, test := range tests {
|
||||
if got := DisplayExperiencedPeople(test.start, test.actual); got != test.want {
|
||||
t.Fatalf("DisplayExperiencedPeople(%d, %d) = %d, want %d", test.start, test.actual, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTicketRules(t *testing.T) {
|
||||
if !CanTransitionTicket(model.TicketCalled, model.TicketArrived) || !CanTransitionTicket(model.TicketArrived, model.TicketCompleted) {
|
||||
t.Fatal("expected normal transitions")
|
||||
|
||||
@@ -8,15 +8,15 @@ import (
|
||||
|
||||
type ETAInput struct {
|
||||
PeopleAhead int
|
||||
IntervalPerNumber time.Duration
|
||||
IntervalPerPerson time.Duration
|
||||
Running bool
|
||||
}
|
||||
|
||||
type ETAResult struct {
|
||||
Available bool `json:"available"`
|
||||
EstimateMinutes int `json:"estimate_minutes,omitempty"`
|
||||
MinMinutes int `json:"min_minutes,omitempty"`
|
||||
MaxMinutes int `json:"max_minutes,omitempty"`
|
||||
EstimateMinutes int `json:"estimate_minutes"`
|
||||
MinMinutes int `json:"min_minutes"`
|
||||
MaxMinutes int `json:"max_minutes"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
@@ -27,10 +27,10 @@ func CalculateETA(input ETAInput) (ETAResult, error) {
|
||||
if input.PeopleAhead < 0 {
|
||||
return ETAResult{}, errors.New("people ahead cannot be negative")
|
||||
}
|
||||
if input.IntervalPerNumber <= 0 {
|
||||
if input.IntervalPerPerson <= 0 {
|
||||
return ETAResult{Available: false, Reason: "missing_interval_configuration"}, nil
|
||||
}
|
||||
rawMinutes := float64(input.PeopleAhead+1) * input.IntervalPerNumber.Minutes()
|
||||
rawMinutes := float64(input.PeopleAhead) * input.IntervalPerPerson.Minutes()
|
||||
estimate := roundUpFive(rawMinutes)
|
||||
return ETAResult{
|
||||
Available: true,
|
||||
|
||||
16
server/internal/domain/experienced_people.go
Normal file
16
server/internal/domain/experienced_people.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package domain
|
||||
|
||||
// DisplayExperiencedPeople applies the project's configured display floor to
|
||||
// the actual number of tickets issued for the current business day.
|
||||
func DisplayExperiencedPeople(start int, actual int64) int64 {
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if actual < 0 {
|
||||
actual = 0
|
||||
}
|
||||
if displayedStart := int64(start); displayedStart > actual {
|
||||
return displayedStart
|
||||
}
|
||||
return actual
|
||||
}
|
||||
Reference in New Issue
Block a user