delivery slot api creation
This commit is contained in:
271
services/deliverySlotService.go
Normal file
271
services/deliverySlotService.go
Normal file
@@ -0,0 +1,271 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nearle/models"
|
||||
"nearle/repositories"
|
||||
)
|
||||
|
||||
/*
|
||||
Delivery windows, and the one rule that decides whether a shopper may pick one.
|
||||
|
||||
── The rule ────────────────────────────────────────────────────────────────
|
||||
|
||||
A slot is on offer while it is active and has not ended. Morning 08:00–10:00
|
||||
takes an order at 09:59 and refuses one at 10:01. There is no separate cut-off
|
||||
to configure: the end of the window IS the cut-off, which is what the shop
|
||||
already told us when they typed it.
|
||||
|
||||
Once the last window of the day has closed, the next thing on offer is tomorrow
|
||||
morning — not nothing. A shopper at 9pm is ordering for tomorrow, and the
|
||||
alternative (offering no slot at all after dark) would quietly drop the feature
|
||||
for every evening customer.
|
||||
|
||||
── Why the rule lives here and nowhere else ────────────────────────────────
|
||||
|
||||
Two callers need it: the app, asking what to show, and order creation, checking
|
||||
what came back. If those disagree by so much as a boundary, the app offers a
|
||||
window the server then rejects, and the shopper is told their basket is invalid
|
||||
for reasons nothing on screen explains. One function, both callers.
|
||||
*/
|
||||
type DeliverySlotService interface {
|
||||
// What a branch has configured, open or closed, for the console to edit.
|
||||
ListForBranch(tenantID, locationID int) ([]models.DeliverySlots, error)
|
||||
// What a shopper may pick right now, already filtered and dated.
|
||||
Available(tenantID, locationID int) ([]models.AvailableDeliverySlot, error)
|
||||
// Create or update the three windows for a branch.
|
||||
Save(tenantID, locationID int, slots []models.DeliverySlots) error
|
||||
// Confirm an order may use this slot on this date. Returns a message fit to
|
||||
// show a shopper when it may not.
|
||||
ValidateForOrder(tenantID, locationID, slotID int, slotDate string) error
|
||||
}
|
||||
|
||||
type deliverySlotService struct {
|
||||
repo repositories.DeliverySlotRepository
|
||||
// Injected so tests can sit at a chosen moment rather than waiting for one.
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewDeliverySlotService(repo repositories.DeliverySlotRepository) DeliverySlotService {
|
||||
return &deliverySlotService{repo: repo, now: time.Now}
|
||||
}
|
||||
|
||||
const slotDateLayout = "2006-01-02"
|
||||
const slotTimeLayout = "15:04"
|
||||
|
||||
func (s *deliverySlotService) ListForBranch(tenantID, locationID int) ([]models.DeliverySlots, error) {
|
||||
if tenantID <= 0 {
|
||||
return nil, fmt.Errorf("tenantid is required")
|
||||
}
|
||||
return s.repo.ListForBranch(tenantID, locationID)
|
||||
}
|
||||
|
||||
/*
|
||||
The windows a shopper may pick, soonest first.
|
||||
|
||||
Today's remaining windows come first, then tomorrow's — so a shopper at 9am sees
|
||||
morning, afternoon and evening, one at 11am sees afternoon and evening, and one
|
||||
at 9pm sees tomorrow's three.
|
||||
|
||||
Returns an EMPTY list, never an error, when a branch has configured nothing.
|
||||
That is the ordinary state for every shop trading today and means "order without
|
||||
a window", not "this shop is shut".
|
||||
*/
|
||||
func (s *deliverySlotService) Available(tenantID, locationID int) ([]models.AvailableDeliverySlot, error) {
|
||||
if tenantID <= 0 {
|
||||
return nil, fmt.Errorf("tenantid is required")
|
||||
}
|
||||
|
||||
configured, err := s.repo.ListForBranch(tenantID, locationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := s.now()
|
||||
today := now.Format(slotDateLayout)
|
||||
tomorrow := now.AddDate(0, 0, 1).Format(slotDateLayout)
|
||||
|
||||
// Non-nil so the JSON is `[]` rather than `null`. An app checking
|
||||
// `length === 0` and one checking for null should not get different answers.
|
||||
available := make([]models.AvailableDeliverySlot, 0, len(configured))
|
||||
|
||||
for _, slot := range configured {
|
||||
if !strings.EqualFold(slot.Status, "active") {
|
||||
continue
|
||||
}
|
||||
end, ok := parseSlotTime(slot.Endtime)
|
||||
if !ok {
|
||||
// A window whose hours cannot be read is skipped rather than
|
||||
// guessed at. Offering a slot we cannot tell the end of would mean
|
||||
// accepting orders against it forever.
|
||||
continue
|
||||
}
|
||||
|
||||
// Today, but only while it is still running.
|
||||
if minutesSinceMidnight(now) < end {
|
||||
available = append(available, toAvailable(slot, today, false))
|
||||
}
|
||||
// And always tomorrow, since by then it has come round again.
|
||||
available = append(available, toAvailable(slot, tomorrow, true))
|
||||
}
|
||||
|
||||
sort.SliceStable(available, func(i, j int) bool {
|
||||
if available[i].Slotdate != available[j].Slotdate {
|
||||
return available[i].Slotdate < available[j].Slotdate
|
||||
}
|
||||
return available[i].Starttime < available[j].Starttime
|
||||
})
|
||||
|
||||
return available, nil
|
||||
}
|
||||
|
||||
func toAvailable(slot models.DeliverySlots, date string, isTomorrow bool) models.AvailableDeliverySlot {
|
||||
name := strings.TrimSpace(slot.Name)
|
||||
if name == "" {
|
||||
// A shop that never renamed the window still needs something readable
|
||||
// on the app's button.
|
||||
name = strings.ToUpper(slot.Slotkey[:1]) + slot.Slotkey[1:]
|
||||
}
|
||||
return models.AvailableDeliverySlot{
|
||||
Slotid: slot.Slotid,
|
||||
Slotkey: slot.Slotkey,
|
||||
Name: name,
|
||||
Starttime: slot.Starttime,
|
||||
Endtime: slot.Endtime,
|
||||
Slotdate: date,
|
||||
IsTomorrow: isTomorrow,
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Write a branch's windows.
|
||||
|
||||
Validates every row before writing any of them: a half-applied set would leave a
|
||||
shop with a morning it just edited and an evening it did not, with nothing on
|
||||
screen saying which took.
|
||||
*/
|
||||
func (s *deliverySlotService) Save(tenantID, locationID int, slots []models.DeliverySlots) error {
|
||||
if tenantID <= 0 {
|
||||
return fmt.Errorf("tenantid is required")
|
||||
}
|
||||
if len(slots) == 0 {
|
||||
return fmt.Errorf("at least one delivery window is required")
|
||||
}
|
||||
|
||||
seen := make(map[string]bool, len(slots))
|
||||
prepared := make([]models.DeliverySlots, 0, len(slots))
|
||||
|
||||
for _, slot := range slots {
|
||||
key := strings.ToLower(strings.TrimSpace(slot.Slotkey))
|
||||
if !models.IsSlotKey(key) {
|
||||
return fmt.Errorf("%q is not a delivery window — expected morning, afternoon or evening", slot.Slotkey)
|
||||
}
|
||||
if seen[key] {
|
||||
return fmt.Errorf("%s was given twice; a branch has one of each", key)
|
||||
}
|
||||
seen[key] = true
|
||||
|
||||
start, startOK := parseSlotTime(slot.Starttime)
|
||||
end, endOK := parseSlotTime(slot.Endtime)
|
||||
if !startOK || !endOK {
|
||||
return fmt.Errorf("%s needs a start and end time as HH:MM", key)
|
||||
}
|
||||
if end <= start {
|
||||
// Not a pedantic check: an end before its start never passes the
|
||||
// "still running" test, so the window would silently never appear.
|
||||
return fmt.Errorf("%s ends at or before it starts (%s–%s)", key, slot.Starttime, slot.Endtime)
|
||||
}
|
||||
|
||||
status := strings.ToLower(strings.TrimSpace(slot.Status))
|
||||
if status != "inactive" {
|
||||
status = "active"
|
||||
}
|
||||
|
||||
prepared = append(prepared, models.DeliverySlots{
|
||||
Tenantid: tenantID,
|
||||
Locationid: locationID,
|
||||
Slotkey: key,
|
||||
Name: strings.TrimSpace(slot.Name),
|
||||
Starttime: slot.Starttime,
|
||||
Endtime: slot.Endtime,
|
||||
Status: status,
|
||||
})
|
||||
}
|
||||
|
||||
return s.repo.Save(prepared)
|
||||
}
|
||||
|
||||
/*
|
||||
May this order use this window?
|
||||
|
||||
Called on every order that names one. The app sends back what `Available` gave
|
||||
it, but the app is not the authority — `/v1/mob/*` carries no session, so
|
||||
anything arriving here is a claim. Re-deciding with the same rule is the only
|
||||
thing standing between a shopper and a window that closed while they were
|
||||
checking out.
|
||||
*/
|
||||
func (s *deliverySlotService) ValidateForOrder(tenantID, locationID, slotID int, slotDate string) error {
|
||||
if slotID <= 0 {
|
||||
return nil // No window chosen. Ordinary — see models/deliveryslot.go.
|
||||
}
|
||||
if tenantID <= 0 {
|
||||
return fmt.Errorf("tenantid is required")
|
||||
}
|
||||
|
||||
slot, err := s.repo.FindForBranch(tenantID, locationID, slotID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if slot == nil {
|
||||
return fmt.Errorf("that delivery window is not one this shop offers")
|
||||
}
|
||||
if !strings.EqualFold(slot.Status, "active") {
|
||||
return fmt.Errorf("the %s window is not currently available", slot.Slotkey)
|
||||
}
|
||||
|
||||
date, err := time.Parse(slotDateLayout, strings.TrimSpace(slotDate))
|
||||
if err != nil {
|
||||
return fmt.Errorf("a delivery date is required with a delivery window")
|
||||
}
|
||||
|
||||
now := s.now()
|
||||
today := now.Truncate(24 * time.Hour)
|
||||
chosen := date.Truncate(24 * time.Hour)
|
||||
|
||||
if chosen.Before(today) {
|
||||
return fmt.Errorf("that delivery window has already passed")
|
||||
}
|
||||
if chosen.Equal(today) {
|
||||
end, ok := parseSlotTime(slot.Endtime)
|
||||
if !ok {
|
||||
return fmt.Errorf("the %s window has no readable end time", slot.Slotkey)
|
||||
}
|
||||
if minutesSinceMidnight(now) >= end {
|
||||
// The exact case this guard exists for: a shopper who sat on the
|
||||
// checkout screen while the window closed.
|
||||
return fmt.Errorf("the %s window has closed for today — please choose another", slot.Slotkey)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// "HH:MM" as minutes past midnight. The second return is false for anything
|
||||
// unreadable, which every caller treats as "do not offer", never as zero —
|
||||
// zero would mean midnight and make a broken window look like one that has
|
||||
// already closed.
|
||||
func parseSlotTime(value string) (int, bool) {
|
||||
parsed, err := time.Parse(slotTimeLayout, strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return parsed.Hour()*60 + parsed.Minute(), true
|
||||
}
|
||||
|
||||
func minutesSinceMidnight(at time.Time) int {
|
||||
return at.Hour()*60 + at.Minute()
|
||||
}
|
||||
257
services/deliverySlotService_test.go
Normal file
257
services/deliverySlotService_test.go
Normal file
@@ -0,0 +1,257 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"nearle/models"
|
||||
)
|
||||
|
||||
// A stand-in for the table, so these tests are about the RULE and not about
|
||||
// GORM. Every test below fixes "now" explicitly, because a rule about clock
|
||||
// time that is tested at whatever o'clock CI happens to run is not tested.
|
||||
type fakeSlotRepo struct {
|
||||
slots []models.DeliverySlots
|
||||
}
|
||||
|
||||
func (f *fakeSlotRepo) ListForBranch(tenantID, locationID int) ([]models.DeliverySlots, error) {
|
||||
return f.slots, nil
|
||||
}
|
||||
|
||||
func (f *fakeSlotRepo) FindForBranch(tenantID, locationID, slotID int) (*models.DeliverySlots, error) {
|
||||
for i := range f.slots {
|
||||
if f.slots[i].Slotid == slotID {
|
||||
return &f.slots[i], nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeSlotRepo) Save(slots []models.DeliverySlots) error {
|
||||
f.slots = slots
|
||||
return nil
|
||||
}
|
||||
|
||||
func threeSlots() []models.DeliverySlots {
|
||||
return []models.DeliverySlots{
|
||||
{Slotid: 1, Slotkey: "morning", Name: "Morning", Starttime: "08:00", Endtime: "10:00", Status: "active"},
|
||||
{Slotid: 2, Slotkey: "afternoon", Name: "Afternoon", Starttime: "12:00", Endtime: "15:00", Status: "active"},
|
||||
{Slotid: 3, Slotkey: "evening", Name: "Evening", Starttime: "17:00", Endtime: "20:00", Status: "active"},
|
||||
}
|
||||
}
|
||||
|
||||
func serviceAt(clock string, slots []models.DeliverySlots) *deliverySlotService {
|
||||
at, err := time.Parse("2006-01-02 15:04", clock)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return &deliverySlotService{
|
||||
repo: &fakeSlotRepo{slots: slots},
|
||||
now: func() time.Time { return at },
|
||||
}
|
||||
}
|
||||
|
||||
// Today's windows that have not ended, soonest first.
|
||||
func todayKeys(t *testing.T, svc *deliverySlotService, date string) []string {
|
||||
t.Helper()
|
||||
available, err := svc.Available(1, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("Available: %v", err)
|
||||
}
|
||||
keys := []string{}
|
||||
for _, slot := range available {
|
||||
if slot.Slotdate == date {
|
||||
keys = append(keys, slot.Slotkey)
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func TestAvailableDropsWindowsThatHaveEnded(t *testing.T) {
|
||||
// The rule the shop was given: a window takes orders right up to the moment
|
||||
// it ends, and then stops being offered.
|
||||
cases := []struct {
|
||||
clock string
|
||||
want string
|
||||
}{
|
||||
{"2026-10-06 07:00", "morning,afternoon,evening"}, // before trading
|
||||
{"2026-10-06 09:59", "morning,afternoon,evening"}, // one minute left
|
||||
{"2026-10-06 10:01", "afternoon,evening"}, // morning just closed
|
||||
{"2026-10-06 15:30", "evening"}, // afternoon gone too
|
||||
{"2026-10-06 20:01", ""}, // day over
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
svc := serviceAt(tc.clock, threeSlots())
|
||||
got := strings.Join(todayKeys(t, svc, "2026-10-06"), ",")
|
||||
if got != tc.want {
|
||||
t.Errorf("at %s: today offers %q, want %q", tc.clock, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailableClosesOnTheEndMinuteNotTheStart(t *testing.T) {
|
||||
// The boundary, stated on its own because it is the one thing a shopkeeper
|
||||
// would notice being wrong. 10:00 exactly is PAST the end of an 08:00-10:00
|
||||
// window: the window is over, and an order placed at 10:00 cannot be in it.
|
||||
svc := serviceAt("2026-10-06 10:00", threeSlots())
|
||||
for _, key := range todayKeys(t, svc, "2026-10-06") {
|
||||
if key == "morning" {
|
||||
t.Error("morning is still on offer at 10:00, its own end time")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailableAlwaysOffersTomorrow(t *testing.T) {
|
||||
// A shopper at 9pm is ordering for tomorrow. Offering nothing would quietly
|
||||
// drop the feature for every evening customer.
|
||||
svc := serviceAt("2026-10-06 21:00", threeSlots())
|
||||
available, err := svc.Available(1, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("Available: %v", err)
|
||||
}
|
||||
|
||||
if len(available) != 3 {
|
||||
t.Fatalf("after the last window closed, got %d slots, want tomorrow's 3", len(available))
|
||||
}
|
||||
for _, slot := range available {
|
||||
if slot.Slotdate != "2026-10-07" {
|
||||
t.Errorf("%s is dated %s, want tomorrow", slot.Slotkey, slot.Slotdate)
|
||||
}
|
||||
if !slot.IsTomorrow {
|
||||
t.Errorf("%s is tomorrow's but istomorrow is false", slot.Slotkey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailableSkipsInactiveWindows(t *testing.T) {
|
||||
slots := threeSlots()
|
||||
slots[1].Status = "inactive" // the shop stopped doing afternoons
|
||||
|
||||
svc := serviceAt("2026-10-06 07:00", slots)
|
||||
for _, key := range todayKeys(t, svc, "2026-10-06") {
|
||||
if key == "afternoon" {
|
||||
t.Error("an inactive window is being offered to shoppers")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailableIsEmptyAndNotAnErrorWhenNothingIsConfigured(t *testing.T) {
|
||||
// The state EVERY shop is in the day this ships. An error here would turn
|
||||
// every trading shop on the platform into a broken one.
|
||||
svc := serviceAt("2026-10-06 09:00", nil)
|
||||
|
||||
available, err := svc.Available(1, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("a branch with no windows must not be an error, got %v", err)
|
||||
}
|
||||
if available == nil {
|
||||
t.Error("got nil, want an empty slice — `null` and `[]` read differently in the app")
|
||||
}
|
||||
if len(available) != 0 {
|
||||
t.Errorf("got %d slots from a branch that configured none", len(available))
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateForOrderAcceptsNoWindow(t *testing.T) {
|
||||
// Every order placed before this shipped, and every order from a branch
|
||||
// that has set no windows.
|
||||
svc := serviceAt("2026-10-06 09:00", threeSlots())
|
||||
if err := svc.ValidateForOrder(1, 1, 0, ""); err != nil {
|
||||
t.Errorf("an order with no window was rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateForOrderRejectsAWindowThatClosedWhileCheckingOut(t *testing.T) {
|
||||
// The case this guard exists for: the shopper opened checkout at 09:58 and
|
||||
// paid at 10:02.
|
||||
svc := serviceAt("2026-10-06 10:02", threeSlots())
|
||||
|
||||
err := svc.ValidateForOrder(1, 1, 1, "2026-10-06")
|
||||
if err == nil {
|
||||
t.Fatal("an order was accepted into a window that had already closed")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "morning") {
|
||||
t.Errorf("the message does not name the window: %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateForOrderAcceptsTomorrowsWindowAfterTodaysClosed(t *testing.T) {
|
||||
// Same window, next day. Closing today must not close it forever.
|
||||
svc := serviceAt("2026-10-06 10:02", threeSlots())
|
||||
if err := svc.ValidateForOrder(1, 1, 1, "2026-10-07"); err != nil {
|
||||
t.Errorf("tomorrow's morning was rejected at 10:02 today: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateForOrderRejectsAWindowFromAnotherShop(t *testing.T) {
|
||||
// The repository scopes its lookup by tenant and branch, so a slot id that
|
||||
// belongs elsewhere comes back as nothing.
|
||||
svc := serviceAt("2026-10-06 09:00", threeSlots())
|
||||
|
||||
if err := svc.ValidateForOrder(1, 1, 99, "2026-10-06"); err == nil {
|
||||
t.Error("an order named a window this shop does not offer and was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateForOrderRequiresADateWithAWindow(t *testing.T) {
|
||||
// "evening" alone cannot say tonight or tomorrow night.
|
||||
svc := serviceAt("2026-10-06 09:00", threeSlots())
|
||||
|
||||
if err := svc.ValidateForOrder(1, 1, 3, ""); err == nil {
|
||||
t.Error("a window was accepted with no date")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveRejectsAWindowThatEndsBeforeItStarts(t *testing.T) {
|
||||
// Not pedantry: such a window never passes the "still running" test, so it
|
||||
// would simply never appear, with nothing saying why.
|
||||
svc := serviceAt("2026-10-06 09:00", nil)
|
||||
|
||||
err := svc.Save(1, 1, []models.DeliverySlots{
|
||||
{Slotkey: "morning", Starttime: "10:00", Endtime: "08:00", Status: "active"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("a window ending before it starts was saved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveRejectsADuplicateWindow(t *testing.T) {
|
||||
svc := serviceAt("2026-10-06 09:00", nil)
|
||||
|
||||
err := svc.Save(1, 1, []models.DeliverySlots{
|
||||
{Slotkey: "morning", Starttime: "08:00", Endtime: "10:00", Status: "active"},
|
||||
{Slotkey: "morning", Starttime: "09:00", Endtime: "11:00", Status: "active"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("a branch was given two mornings")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveRejectsAKeyThatIsNotOneOfTheThree(t *testing.T) {
|
||||
svc := serviceAt("2026-10-06 09:00", nil)
|
||||
|
||||
err := svc.Save(1, 1, []models.DeliverySlots{
|
||||
{Slotkey: "midnight", Starttime: "00:00", Endtime: "02:00", Status: "active"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("a fourth window was accepted; the app has nowhere to show it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAppliesNothingWhenOneRowIsWrong(t *testing.T) {
|
||||
// A shop editing three and getting two is worse than getting none: the two
|
||||
// that took are live, and nothing on screen says which.
|
||||
repo := &fakeSlotRepo{}
|
||||
svc := &deliverySlotService{repo: repo, now: time.Now}
|
||||
|
||||
_ = svc.Save(1, 1, []models.DeliverySlots{
|
||||
{Slotkey: "morning", Starttime: "08:00", Endtime: "10:00", Status: "active"},
|
||||
{Slotkey: "afternoon", Starttime: "15:00", Endtime: "12:00", Status: "active"}, // bad
|
||||
})
|
||||
|
||||
if len(repo.slots) != 0 {
|
||||
t.Errorf("%d windows were written despite one being invalid", len(repo.slots))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user